From b4a22b729a46619133d477129ab7596219891450 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 15:41:39 +0100 Subject: [PATCH 001/223] feat(fs): Enhance API for incremental build, add tracking readers/writers Cherry-picked from https://github.com/SAP/ui5-fs/commit/5651627b91109b8059d4c9401c192e6c9d757e60 JIRA: CPOUI5FOUNDATION-1174 --- packages/fs/lib/DuplexTracker.js | 84 ++++++++++++ .../fs/lib/ReaderCollectionPrioritized.js | 2 +- packages/fs/lib/Resource.js | 127 ++++++++++++++---- packages/fs/lib/ResourceFacade.js | 14 ++ packages/fs/lib/Tracker.js | 69 ++++++++++ packages/fs/lib/adapters/AbstractAdapter.js | 54 ++++++-- packages/fs/lib/adapters/FileSystem.js | 28 ++-- packages/fs/lib/adapters/Memory.js | 34 +---- packages/fs/lib/readers/Filter.js | 4 +- packages/fs/lib/readers/Link.js | 44 +++--- packages/fs/lib/resourceFactory.js | 24 +++- 11 files changed, 372 insertions(+), 112 deletions(-) create mode 100644 packages/fs/lib/DuplexTracker.js create mode 100644 packages/fs/lib/Tracker.js diff --git a/packages/fs/lib/DuplexTracker.js b/packages/fs/lib/DuplexTracker.js new file mode 100644 index 00000000000..2ccdb56b1a4 --- /dev/null +++ b/packages/fs/lib/DuplexTracker.js @@ -0,0 +1,84 @@ +import AbstractReaderWriter from "./AbstractReaderWriter.js"; + +// TODO: Alternative name: Inspector/Interceptor/... + +export default class Trace extends AbstractReaderWriter { + #readerWriter; + #sealed = false; + #pathsRead = []; + #patterns = []; + #resourcesRead = Object.create(null); + #resourcesWritten = Object.create(null); + + constructor(readerWriter) { + super(readerWriter.getName()); + this.#readerWriter = readerWriter; + } + + getResults() { + this.#sealed = true; + return { + requests: { + pathsRead: this.#pathsRead, + patterns: this.#patterns, + }, + resourcesRead: this.#resourcesRead, + resourcesWritten: this.#resourcesWritten, + }; + } + + async _byGlob(virPattern, options, trace) { + if (this.#sealed) { + throw new Error(`Unexpected read operation after reader has been sealed`); + } + if (this.#readerWriter.resolvePattern) { + const resolvedPattern = this.#readerWriter.resolvePattern(virPattern); + this.#patterns.push(resolvedPattern); + } else if (virPattern instanceof Array) { + for (const pattern of virPattern) { + this.#patterns.push(pattern); + } + } else { + this.#patterns.push(virPattern); + } + const resources = await this.#readerWriter._byGlob(virPattern, options, trace); + for (const resource of resources) { + if (!resource.getStatInfo()?.isDirectory()) { + this.#resourcesRead[resource.getOriginalPath()] = resource; + } + } + return resources; + } + + async _byPath(virPath, options, trace) { + if (this.#sealed) { + throw new Error(`Unexpected read operation after reader has been sealed`); + } + if (this.#readerWriter.resolvePath) { + const resolvedPath = this.#readerWriter.resolvePath(virPath); + if (resolvedPath) { + this.#pathsRead.push(resolvedPath); + } + } else { + this.#pathsRead.push(virPath); + } + const resource = await this.#readerWriter._byPath(virPath, options, trace); + if (resource) { + if (!resource.getStatInfo()?.isDirectory()) { + this.#resourcesRead[resource.getOriginalPath()] = resource; + } + } + return resource; + } + + async _write(resource, options) { + if (this.#sealed) { + throw new Error(`Unexpected write operation after writer has been sealed`); + } + if (!resource) { + throw new Error(`Cannot write undefined resource`); + } + this.#resourcesWritten[resource.getOriginalPath()] = resource; + return this.#readerWriter.write(resource, options); + } +} diff --git a/packages/fs/lib/ReaderCollectionPrioritized.js b/packages/fs/lib/ReaderCollectionPrioritized.js index 680b71357ca..8f235f22148 100644 --- a/packages/fs/lib/ReaderCollectionPrioritized.js +++ b/packages/fs/lib/ReaderCollectionPrioritized.js @@ -68,7 +68,7 @@ class ReaderCollectionPrioritized extends AbstractReader { * @returns {Promise<@ui5/fs/Resource|null>} * Promise resolving to a single resource or null if no resource is found */ - _byPath(virPath, options, trace) { + async _byPath(virPath, options, trace) { const that = this; const byPath = (i) => { if (i > this._readers.length - 1) { diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index c43edc2716f..1cefc2ce490 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -1,9 +1,8 @@ import stream from "node:stream"; +import crypto from "node:crypto"; import clone from "clone"; import posixPath from "node:path/posix"; -const fnTrue = () => true; -const fnFalse = () => false; const ALLOWED_SOURCE_METADATA_KEYS = ["adapter", "fsPath", "contentModified"]; /** @@ -100,24 +99,6 @@ class Resource { this.#project = project; - this.#statInfo = statInfo || { // TODO - isFile: fnTrue, - isDirectory: fnFalse, - isBlockDevice: fnFalse, - isCharacterDevice: fnFalse, - isSymbolicLink: fnFalse, - isFIFO: fnFalse, - isSocket: fnFalse, - atimeMs: new Date().getTime(), - mtimeMs: new Date().getTime(), - ctimeMs: new Date().getTime(), - birthtimeMs: new Date().getTime(), - atime: new Date(), - mtime: new Date(), - ctime: new Date(), - birthtime: new Date() - }; - if (createStream) { this.#createStream = createStream; } else if (stream) { @@ -130,6 +111,15 @@ class Resource { this.#setBuffer(Buffer.from(string, "utf8")); } + if (statInfo) { + this.#statInfo = parseStat(statInfo); + } else { + if (createStream || stream) { + throw new Error("Unable to create Resource: Please provide statInfo for stream content"); + } + this.#statInfo = createStat(this.#buffer.byteLength); + } + // Tracing: this.#collections = []; } @@ -164,6 +154,7 @@ class Resource { setBuffer(buffer) { this.#sourceMetadata.contentModified = true; this.#isModified = true; + this.#updateStatInfo(buffer); this.#setBuffer(buffer); } @@ -269,6 +260,21 @@ class Resource { this.#streamDrained = false; } + async getHash() { + if (this.#statInfo.isDirectory()) { + return; + } + const buffer = await this.getBuffer(); + return crypto.createHash("md5").update(buffer).digest("hex"); + } + + #updateStatInfo(buffer) { + const now = new Date(); + this.#statInfo.mtimeMs = now.getTime(); + this.#statInfo.mtime = now; + this.#statInfo.size = buffer.byteLength; + } + /** * Gets the virtual resources path * @@ -279,6 +285,10 @@ class Resource { return this.#path; } + getOriginalPath() { + return this.#path; + } + /** * Sets the virtual resources path * @@ -318,6 +328,10 @@ class Resource { return this.#statInfo; } + getLastModified() { + + } + /** * Size in bytes allocated by the underlying buffer. * @@ -325,12 +339,13 @@ class Resource { * @returns {Promise} size in bytes, 0 if there is no content yet */ async getSize() { - // if resource does not have any content it should have 0 bytes - if (!this.#buffer && !this.#createStream && !this.#stream) { - return 0; - } - const buffer = await this.getBuffer(); - return buffer.byteLength; + return this.#statInfo.size; + // // if resource does not have any content it should have 0 bytes + // if (!this.#buffer && !this.#createStream && !this.#stream) { + // return 0; + // } + // const buffer = await this.getBuffer(); + // return buffer.byteLength; } /** @@ -356,7 +371,7 @@ class Resource { async #getCloneOptions() { const options = { path: this.#path, - statInfo: clone(this.#statInfo), + statInfo: this.#statInfo, // Will be cloned in constructor sourceMetadata: clone(this.#sourceMetadata) }; @@ -495,4 +510,62 @@ class Resource { } } +const fnTrue = function() { + return true; +}; +const fnFalse = function() { + return false; +}; + +/** + * Parses a Node.js stat object to a UI5 Tooling stat object + * + * @param {fs.Stats} statInfo Node.js stat + * @returns {object} UI5 Tooling stat +*/ +function parseStat(statInfo) { + return { + isFile: statInfo.isFile.bind(statInfo), + isDirectory: statInfo.isDirectory.bind(statInfo), + isBlockDevice: statInfo.isBlockDevice.bind(statInfo), + isCharacterDevice: statInfo.isCharacterDevice.bind(statInfo), + isSymbolicLink: statInfo.isSymbolicLink.bind(statInfo), + isFIFO: statInfo.isFIFO.bind(statInfo), + isSocket: statInfo.isSocket.bind(statInfo), + ino: statInfo.ino, + size: statInfo.size, + atimeMs: statInfo.atimeMs, + mtimeMs: statInfo.mtimeMs, + ctimeMs: statInfo.ctimeMs, + birthtimeMs: statInfo.birthtimeMs, + atime: statInfo.atime, + mtime: statInfo.mtime, + ctime: statInfo.ctime, + birthtime: statInfo.birthtime, + }; +} + +function createStat(size) { + const now = new Date(); + return { + isFile: fnTrue, + isDirectory: fnFalse, + isBlockDevice: fnFalse, + isCharacterDevice: fnFalse, + isSymbolicLink: fnFalse, + isFIFO: fnFalse, + isSocket: fnFalse, + ino: 0, + size, + atimeMs: now.getTime(), + mtimeMs: now.getTime(), + ctimeMs: now.getTime(), + birthtimeMs: now.getTime(), + atime: now, + mtime: now, + ctime: now, + birthtime: now, + }; +} + export default Resource; diff --git a/packages/fs/lib/ResourceFacade.js b/packages/fs/lib/ResourceFacade.js index 58ba37b2a4d..9604b56acd1 100644 --- a/packages/fs/lib/ResourceFacade.js +++ b/packages/fs/lib/ResourceFacade.js @@ -45,6 +45,16 @@ class ResourceFacade { return this.#path; } + /** + * Gets the resources path + * + * @public + * @returns {string} (Virtual) path of the resource + */ + getOriginalPath() { + return this.#resource.getPath(); + } + /** * Gets the resource name * @@ -150,6 +160,10 @@ class ResourceFacade { return this.#resource.setStream(stream); } + getHash() { + return this.#resource.getHash(); + } + /** * Gets the resources stat info. * Note that a resources stat information is not updated when the resource is being modified. diff --git a/packages/fs/lib/Tracker.js b/packages/fs/lib/Tracker.js new file mode 100644 index 00000000000..ed19019e364 --- /dev/null +++ b/packages/fs/lib/Tracker.js @@ -0,0 +1,69 @@ +import AbstractReader from "./AbstractReader.js"; + +export default class Trace extends AbstractReader { + #reader; + #sealed = false; + #pathsRead = []; + #patterns = []; + #resourcesRead = Object.create(null); + + constructor(reader) { + super(reader.getName()); + this.#reader = reader; + } + + getResults() { + this.#sealed = true; + return { + requests: { + pathsRead: this.#pathsRead, + patterns: this.#patterns, + }, + resourcesRead: this.#resourcesRead, + }; + } + + async _byGlob(virPattern, options, trace) { + if (this.#sealed) { + throw new Error(`Unexpected read operation after reader has been sealed`); + } + if (this.#reader.resolvePattern) { + const resolvedPattern = this.#reader.resolvePattern(virPattern); + this.#patterns.push(resolvedPattern); + } else if (virPattern instanceof Array) { + for (const pattern of virPattern) { + this.#patterns.push(pattern); + } + } else { + this.#patterns.push(virPattern); + } + const resources = await this.#reader._byGlob(virPattern, options, trace); + for (const resource of resources) { + if (!resource.getStatInfo()?.isDirectory()) { + this.#resourcesRead[resource.getOriginalPath()] = resource; + } + } + return resources; + } + + async _byPath(virPath, options, trace) { + if (this.#sealed) { + throw new Error(`Unexpected read operation after reader has been sealed`); + } + if (this.#reader.resolvePath) { + const resolvedPath = this.#reader.resolvePath(virPath); + if (resolvedPath) { + this.#pathsRead.push(resolvedPath); + } + } else { + this.#pathsRead.push(virPath); + } + const resource = await this.#reader._byPath(virPath, options, trace); + if (resource) { + if (!resource.getStatInfo()?.isDirectory()) { + this.#resourcesRead[resource.getOriginalPath()] = resource; + } + } + return resource; + } +} diff --git a/packages/fs/lib/adapters/AbstractAdapter.js b/packages/fs/lib/adapters/AbstractAdapter.js index 96cf4154250..4d2387de80b 100644 --- a/packages/fs/lib/adapters/AbstractAdapter.js +++ b/packages/fs/lib/adapters/AbstractAdapter.js @@ -17,20 +17,20 @@ import Resource from "../Resource.js"; */ class AbstractAdapter extends AbstractReaderWriter { /** - * The constructor * * @public * @param {object} parameters Parameters + * @param {string} parameters.name * @param {string} parameters.virBasePath * Virtual base path. Must be absolute, POSIX-style, and must end with a slash * @param {string[]} [parameters.excludes] List of glob patterns to exclude * @param {object} [parameters.project] Experimental, internal parameter. Do not use */ - constructor({virBasePath, excludes = [], project}) { + constructor({name, virBasePath, excludes = [], project}) { if (new.target === AbstractAdapter) { throw new TypeError("Class 'AbstractAdapter' is abstract"); } - super(); + super(name); if (!virBasePath) { throw new Error(`Unable to create adapter: Missing parameter 'virBasePath'`); @@ -81,17 +81,7 @@ class AbstractAdapter extends AbstractReaderWriter { if (patterns[i] && idx !== -1 && idx < this._virBaseDir.length) { const subPath = patterns[i]; return [ - this._createResource({ - statInfo: { // TODO: make closer to fs stat info - isDirectory: function() { - return true; - } - }, - source: { - adapter: "Abstract" - }, - path: subPath - }) + this._createDirectoryResource(subPath) ]; } } @@ -201,6 +191,10 @@ class AbstractAdapter extends AbstractReaderWriter { if (this._project) { parameters.project = this._project; } + if (!parameters.source) { + parameters.source = Object.create(null); + } + parameters.source.adapter = this.constructor.name; return new Resource(parameters); } @@ -289,6 +283,38 @@ class AbstractAdapter extends AbstractReaderWriter { const relPath = virPath.substr(this._virBasePath.length); return relPath; } + + _createDirectoryResource(dirPath) { + const now = new Date(); + const fnFalse = function() { + return false; + }; + const fnTrue = function() { + return true; + }; + const statInfo = { + isFile: fnFalse, + isDirectory: fnTrue, + isBlockDevice: fnFalse, + isCharacterDevice: fnFalse, + isSymbolicLink: fnFalse, + isFIFO: fnFalse, + isSocket: fnFalse, + size: 0, + atimeMs: now.getTime(), + mtimeMs: now.getTime(), + ctimeMs: now.getTime(), + birthtimeMs: now.getTime(), + atime: now, + mtime: now, + ctime: now, + birthtime: now, + }; + return this._createResource({ + statInfo: statInfo, + path: dirPath, + }); + } } export default AbstractAdapter; diff --git a/packages/fs/lib/adapters/FileSystem.js b/packages/fs/lib/adapters/FileSystem.js index d086fac40dd..284d95d84a4 100644 --- a/packages/fs/lib/adapters/FileSystem.js +++ b/packages/fs/lib/adapters/FileSystem.js @@ -12,7 +12,7 @@ import {PassThrough} from "node:stream"; import AbstractAdapter from "./AbstractAdapter.js"; const READ_ONLY_MODE = 0o444; -const ADAPTER_NAME = "FileSystem"; + /** * File system resource adapter * @@ -23,9 +23,9 @@ const ADAPTER_NAME = "FileSystem"; */ class FileSystem extends AbstractAdapter { /** - * The Constructor. * * @param {object} parameters Parameters + * @param {string} parameters.name * @param {string} parameters.virBasePath * Virtual base path. Must be absolute, POSIX-style, and must end with a slash * @param {string} parameters.fsBasePath @@ -35,8 +35,8 @@ class FileSystem extends AbstractAdapter { * Whether to apply any excludes defined in an optional .gitignore in the given fsBasePath directory * @param {@ui5/project/specifications/Project} [parameters.project] Project this adapter belongs to (if any) */ - constructor({virBasePath, project, fsBasePath, excludes, useGitignore=false}) { - super({virBasePath, project, excludes}); + constructor({name, virBasePath, project, fsBasePath, excludes, useGitignore=false}) { + super({name, virBasePath, project, excludes}); if (!fsBasePath) { throw new Error(`Unable to create adapter: Missing parameter 'fsBasePath'`); @@ -80,7 +80,7 @@ class FileSystem extends AbstractAdapter { statInfo: stat, path: this._virBaseDir, sourceMetadata: { - adapter: ADAPTER_NAME, + adapter: this.constructor.name, fsPath: this._fsBasePath }, createStream: () => { @@ -124,7 +124,7 @@ class FileSystem extends AbstractAdapter { statInfo: stat, path: virPath, sourceMetadata: { - adapter: ADAPTER_NAME, + adapter: this.constructor.name, fsPath: fsPath }, createStream: () => { @@ -158,16 +158,8 @@ class FileSystem extends AbstractAdapter { // Neither starts with basePath, nor equals baseDirectory if (!options.nodir && this._virBasePath.startsWith(virPath)) { // Create virtual directories for the virtual base path (which has to exist) - // TODO: Maybe improve this by actually matching the base paths segments to the virPath - return this._createResource({ - project: this._project, - statInfo: { // TODO: make closer to fs stat info - isDirectory: function() { - return true; - } - }, - path: virPath - }); + // FUTURE: Maybe improve this by actually matching the base paths segments to the virPath + return this._createDirectoryResource(virPath); } else { return null; } @@ -200,7 +192,7 @@ class FileSystem extends AbstractAdapter { statInfo, path: virPath, sourceMetadata: { - adapter: ADAPTER_NAME, + adapter: this.constructor.name, fsPath } }; @@ -260,7 +252,7 @@ class FileSystem extends AbstractAdapter { await mkdir(dirPath, {recursive: true}); const sourceMetadata = resource.getSourceMetadata(); - if (sourceMetadata && sourceMetadata.adapter === ADAPTER_NAME && sourceMetadata.fsPath) { + if (sourceMetadata && sourceMetadata.adapter === this.constructor.name && sourceMetadata.fsPath) { // Resource has been created by FileSystem adapter. This means it might require special handling /* The following code covers these four conditions: diff --git a/packages/fs/lib/adapters/Memory.js b/packages/fs/lib/adapters/Memory.js index 35be99cf953..7215e2ab189 100644 --- a/packages/fs/lib/adapters/Memory.js +++ b/packages/fs/lib/adapters/Memory.js @@ -3,8 +3,6 @@ const log = getLogger("resources:adapters:Memory"); import micromatch from "micromatch"; import AbstractAdapter from "./AbstractAdapter.js"; -const ADAPTER_NAME = "Memory"; - /** * Virtual resource Adapter * @@ -15,17 +13,17 @@ const ADAPTER_NAME = "Memory"; */ class Memory extends AbstractAdapter { /** - * The constructor. * * @public * @param {object} parameters Parameters + * @param {string} parameters.name * @param {string} parameters.virBasePath * Virtual base path. Must be absolute, POSIX-style, and must end with a slash * @param {string[]} [parameters.excludes] List of glob patterns to exclude * @param {@ui5/project/specifications/Project} [parameters.project] Project this adapter belongs to (if any) */ - constructor({virBasePath, project, excludes}) { - super({virBasePath, project, excludes}); + constructor({name, virBasePath, project, excludes}) { + super({name, virBasePath, project, excludes}); this._virFiles = Object.create(null); // map full of files this._virDirs = Object.create(null); // map full of directories } @@ -72,18 +70,7 @@ class Memory extends AbstractAdapter { async _runGlob(patterns, options = {nodir: true}, trace) { if (patterns[0] === "" && !options.nodir) { // Match virtual root directory return [ - this._createResource({ - project: this._project, - statInfo: { // TODO: make closer to fs stat info - isDirectory: function() { - return true; - } - }, - sourceMetadata: { - adapter: ADAPTER_NAME - }, - path: this._virBasePath.slice(0, -1) - }) + this._createDirectoryResource(this._virBasePath.slice(0, -1)) ]; } @@ -157,18 +144,7 @@ class Memory extends AbstractAdapter { for (let i = pathSegments.length - 1; i >= 0; i--) { const segment = pathSegments[i]; if (!this._virDirs[segment]) { - this._virDirs[segment] = this._createResource({ - project: this._project, - sourceMetadata: { - adapter: ADAPTER_NAME - }, - statInfo: { // TODO: make closer to fs stat info - isDirectory: function() { - return true; - } - }, - path: this._virBasePath + segment - }); + this._virDirs[segment] = this._createDirectoryResource(this._virBasePath + segment); } } } diff --git a/packages/fs/lib/readers/Filter.js b/packages/fs/lib/readers/Filter.js index b95654daa29..1e4cf31e727 100644 --- a/packages/fs/lib/readers/Filter.js +++ b/packages/fs/lib/readers/Filter.js @@ -27,8 +27,8 @@ class Filter extends AbstractReader { * @param {@ui5/fs/readers/Filter~callback} parameters.callback * Filter function. Will be called for every resource read through this reader. */ - constructor({reader, callback}) { - super(); + constructor({name, reader, callback}) { + super(name); if (!reader) { throw new Error(`Missing parameter "reader"`); } diff --git a/packages/fs/lib/readers/Link.js b/packages/fs/lib/readers/Link.js index 726a22b763b..fe59fd10295 100644 --- a/packages/fs/lib/readers/Link.js +++ b/packages/fs/lib/readers/Link.js @@ -45,8 +45,8 @@ class Link extends AbstractReader { * @param {@ui5/fs/AbstractReader} parameters.reader The resource reader or collection to wrap * @param {@ui5/fs/readers/Link/PathMapping} parameters.pathMapping */ - constructor({reader, pathMapping}) { - super(); + constructor({name, reader, pathMapping}) { + super(name); if (!reader) { throw new Error(`Missing parameter "reader"`); } @@ -58,17 +58,7 @@ class Link extends AbstractReader { Link._validatePathMapping(pathMapping); } - /** - * Locates resources by glob. - * - * @private - * @param {string|string[]} patterns glob pattern as string or an array of - * glob patterns for virtual directory structure - * @param {object} options glob options - * @param {@ui5/fs/tracing/Trace} trace Trace instance - * @returns {Promise<@ui5/fs/Resource[]>} Promise resolving to list of resources - */ - async _byGlob(patterns, options, trace) { + resolvePattern(patterns) { if (!(patterns instanceof Array)) { patterns = [patterns]; } @@ -80,7 +70,29 @@ class Link extends AbstractReader { }); // Flatten prefixed patterns - patterns = Array.prototype.concat.apply([], patterns); + return Array.prototype.concat.apply([], patterns); + } + + resolvePath(virPath) { + if (!virPath.startsWith(this._pathMapping.linkPath)) { + return null; + } + const targetPath = this._pathMapping.targetPath + virPath.substr(this._pathMapping.linkPath.length); + return targetPath; + } + + /** + * Locates resources by glob. + * + * @private + * @param {string|string[]} patterns glob pattern as string or an array of + * glob patterns for virtual directory structure + * @param {object} options glob options + * @param {@ui5/fs/tracing/Trace} trace Trace instance + * @returns {Promise<@ui5/fs/Resource[]>} Promise resolving to list of resources + */ + async _byGlob(patterns, options, trace) { + patterns = this.resolvePattern(patterns); // Keep resource's internal path unchanged for now const resources = await this._reader._byGlob(patterns, options, trace); @@ -105,10 +117,10 @@ class Link extends AbstractReader { * @returns {Promise<@ui5/fs/Resource>} Promise resolving to a single resource */ async _byPath(virPath, options, trace) { - if (!virPath.startsWith(this._pathMapping.linkPath)) { + const targetPath = this.resolvePath(virPath); + if (!targetPath) { return null; } - const targetPath = this._pathMapping.targetPath + virPath.substr(this._pathMapping.linkPath.length); log.silly(`byPath: Rewriting virtual path ${virPath} to ${targetPath}`); const resource = await this._reader._byPath(targetPath, options, trace); diff --git a/packages/fs/lib/resourceFactory.js b/packages/fs/lib/resourceFactory.js index 6a98c9a7961..282b2ae4ce7 100644 --- a/packages/fs/lib/resourceFactory.js +++ b/packages/fs/lib/resourceFactory.js @@ -9,6 +9,8 @@ import Resource from "./Resource.js"; import WriterCollection from "./WriterCollection.js"; import Filter from "./readers/Filter.js"; import Link from "./readers/Link.js"; +import Tracker from "./Tracker.js"; +import DuplexTracker from "./DuplexTracker.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("resources:resourceFactory"); @@ -26,6 +28,7 @@ const log = getLogger("resources:resourceFactory"); * * @public * @param {object} parameters Parameters + * @param {string} parameters.name * @param {string} parameters.virBasePath Virtual base path. Must be absolute, POSIX-style, and must end with a slash * @param {string} [parameters.fsBasePath] * File System base path. @@ -38,11 +41,11 @@ const log = getLogger("resources:resourceFactory"); * @param {@ui5/project/specifications/Project} [parameters.project] Project this adapter belongs to (if any) * @returns {@ui5/fs/adapters/FileSystem|@ui5/fs/adapters/Memory} File System- or Virtual Adapter */ -export function createAdapter({fsBasePath, virBasePath, project, excludes, useGitignore}) { +export function createAdapter({name, fsBasePath, virBasePath, project, excludes, useGitignore}) { if (fsBasePath) { - return new FsAdapter({fsBasePath, virBasePath, project, excludes, useGitignore}); + return new FsAdapter({name, fsBasePath, virBasePath, project, excludes, useGitignore}); } else { - return new MemAdapter({virBasePath, project, excludes}); + return new MemAdapter({name, virBasePath, project, excludes}); } } @@ -178,15 +181,17 @@ export function createResource(parameters) { export function createWorkspace({reader, writer, virBasePath = "/", name = "workspace"}) { if (!writer) { writer = new MemAdapter({ + name: `Workspace writer for ${name}`, virBasePath }); } - return new DuplexCollection({ + const d = new DuplexCollection({ reader, writer, name }); + return d; } /** @@ -243,12 +248,14 @@ export function createLinkReader(parameters) { * * @public * @param {object} parameters + * @param {string} parameters.name * @param {@ui5/fs/AbstractReader} parameters.reader Single reader or collection of readers * @param {string} parameters.namespace Project namespace * @returns {@ui5/fs/readers/Link} Reader instance */ -export function createFlatReader({reader, namespace}) { +export function createFlatReader({name, reader, namespace}) { return new Link({ + name, reader: reader, pathMapping: { linkPath: `/`, @@ -257,6 +264,13 @@ export function createFlatReader({reader, namespace}) { }); } +export function createTracker(readerWriter) { + if (readerWriter instanceof DuplexCollection) { + return new DuplexTracker(readerWriter); + } + return new Tracker(readerWriter); +} + /** * Normalizes virtual glob patterns by prefixing them with * a given virtual base directory path From 792efc16c93afcd9f977990322cf3c0fae581ac5 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 15:43:27 +0100 Subject: [PATCH 002/223] feat(server): Use incremental build in server Cherry-picked from: https://github.com/SAP/ui5-fs/commit/5651627b91109b8059d4c9401c192e6c9d757e60 JIRA: CPOUI5FOUNDATION-1174 --- .../lib/middleware/MiddlewareManager.js | 2 +- packages/server/lib/server.js | 71 +++++++++++++------ 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/packages/server/lib/middleware/MiddlewareManager.js b/packages/server/lib/middleware/MiddlewareManager.js index 36894892e4d..a06a2475300 100644 --- a/packages/server/lib/middleware/MiddlewareManager.js +++ b/packages/server/lib/middleware/MiddlewareManager.js @@ -218,7 +218,7 @@ class MiddlewareManager { }); await this.addMiddleware("serveResources"); await this.addMiddleware("testRunner"); - await this.addMiddleware("serveThemes"); + // await this.addMiddleware("serveThemes"); await this.addMiddleware("versionInfo", { mountPath: "/resources/sap-ui-version.json" }); diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 5ea5ff8812d..54ec6664276 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,7 +1,8 @@ import express from "express"; import portscanner from "portscanner"; +import path from "node:path/posix"; import MiddlewareManager from "./middleware/MiddlewareManager.js"; -import {createReaderCollection} from "@ui5/fs/resourceFactory"; +import {createAdapter, createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; @@ -137,34 +138,58 @@ export async function serve(graph, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false }) { - const rootProject = graph.getRoot(); + // const rootReader = createAdapter({ + // virBasePath: "/", + // }); + // const dependencies = createAdapter({ + // virBasePath: "/", + // }); - const readers = []; - await graph.traverseBreadthFirst(async function({project: dep}) { - if (dep.getName() === rootProject.getName()) { - // Ignore root project - return; - } - readers.push(dep.getReader({style: "runtime"})); + const rootProject = graph.getRoot(); + const watchHandler = await graph.build({ + cacheDir: path.join(rootProject.getRootPath(), ".ui5-cache"), + includedDependencies: ["*"], + watch: true, }); - const dependencies = createReaderCollection({ - name: `Dependency reader collection for project ${rootProject.getName()}`, - readers - }); + async function createReaders() { + const readers = []; + await graph.traverseBreadthFirst(async function({project: dep}) { + if (dep.getName() === rootProject.getName()) { + // Ignore root project + return; + } + readers.push(dep.getReader({style: "runtime"})); + }); - const rootReader = rootProject.getReader({style: "runtime"}); + const dependencies = createReaderCollection({ + name: `Dependency reader collection for project ${rootProject.getName()}`, + readers + }); + + const rootReader = rootProject.getReader({style: "runtime"}); + // TODO change to ReaderCollection once duplicates are sorted out + const combo = new ReaderCollectionPrioritized({ + name: "server - prioritize workspace over dependencies", + readers: [rootReader, dependencies] + }); + const resources = { + rootProject: rootReader, + dependencies: dependencies, + all: combo + }; + return resources; + } + + const resources = await createReaders(); - // TODO change to ReaderCollection once duplicates are sorted out - const combo = new ReaderCollectionPrioritized({ - name: "server - prioritize workspace over dependencies", - readers: [rootReader, dependencies] + watchHandler.on("buildUpdated", async () => { + const newResources = await createReaders(); + // Patch resources + resources.rootProject = newResources.rootProject; + resources.dependencies = newResources.dependencies; + resources.all = newResources.all; }); - const resources = { - rootProject: rootReader, - dependencies: dependencies, - all: combo - }; const middlewareManager = new MiddlewareManager({ graph, From 853f1e19bf06e1e600069fd8158282082aa63116 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 15:46:24 +0100 Subject: [PATCH 003/223] feat(builder): Adapt tasks for incremental build Cherry-picked from: https://github.com/SAP/ui5-builder/commit/ef5a3b2f6ca0339a8ff8c30997e884e462fa6ab9 JIRA: CPOUI5FOUNDATION-1174 --- .../builder/lib/processors/nonAsciiEscaper.js | 2 +- .../builder/lib/processors/stringReplacer.js | 3 +- .../lib/tasks/escapeNonAsciiCharacters.js | 11 ++++-- packages/builder/lib/tasks/minify.js | 14 +++++-- .../builder/lib/tasks/replaceBuildtime.js | 36 +++++++++--------- .../builder/lib/tasks/replaceCopyright.js | 37 ++++++++++--------- packages/builder/lib/tasks/replaceVersion.js | 35 ++++++++++-------- 7 files changed, 80 insertions(+), 58 deletions(-) diff --git a/packages/builder/lib/processors/nonAsciiEscaper.js b/packages/builder/lib/processors/nonAsciiEscaper.js index ff9d58e97d6..493c680453b 100644 --- a/packages/builder/lib/processors/nonAsciiEscaper.js +++ b/packages/builder/lib/processors/nonAsciiEscaper.js @@ -83,8 +83,8 @@ async function nonAsciiEscaper({resources, options: {encoding}}) { // only modify the resource's string if it was changed if (escaped.modified) { resource.setString(escaped.string); + return resource; } - return resource; } return Promise.all(resources.map(processResource)); diff --git a/packages/builder/lib/processors/stringReplacer.js b/packages/builder/lib/processors/stringReplacer.js index 2485032cc76..5002d426239 100644 --- a/packages/builder/lib/processors/stringReplacer.js +++ b/packages/builder/lib/processors/stringReplacer.js @@ -23,7 +23,8 @@ export default function({resources, options: {pattern, replacement}}) { const newContent = content.replaceAll(pattern, replacement); if (content !== newContent) { resource.setString(newContent); + return resource; } - return resource; + // return resource; })); } diff --git a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js index 53cb3e8d9f3..73943c04a34 100644 --- a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js +++ b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js @@ -19,12 +19,17 @@ import nonAsciiEscaper from "../processors/nonAsciiEscaper.js"; * @param {string} parameters.options.encoding source file encoding either "UTF-8" or "ISO-8859-1" * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, options: {pattern, encoding}}) { +export default async function({workspace, invalidatedResources, options: {pattern, encoding}}) { if (!encoding) { throw new Error("[escapeNonAsciiCharacters] Mandatory option 'encoding' not provided"); } - const allResources = await workspace.byGlob(pattern); + let allResources; + if (invalidatedResources) { + allResources = await Promise.all(invalidatedResources.map((resource) => workspace.byPath(resource))); + } else { + allResources = await workspace.byGlob(pattern); + } const processedResources = await nonAsciiEscaper({ resources: allResources, @@ -33,5 +38,5 @@ export default async function({workspace, options: {pattern, encoding}}) { } }); - await Promise.all(processedResources.map((resource) => workspace.write(resource))); + await Promise.all(processedResources.map((resource) => resource && workspace.write(resource))); } diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index 2969ca688dc..f79c3391cd6 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -26,9 +26,17 @@ import fsInterface from "@ui5/fs/fsInterface"; * @returns {Promise} Promise resolving with undefined once data has been written */ export default async function({ - workspace, taskUtil, options: {pattern, omitSourceMapResources = false, useInputSourceMaps = true - }}) { - const resources = await workspace.byGlob(pattern); + workspace, taskUtil, buildCache, + options: {pattern, omitSourceMapResources = false, useInputSourceMaps = true} +}) { + let resources = await workspace.byGlob(pattern); + if (buildCache.hasCache()) { + const changedPaths = buildCache.getChangedProjectResourcePaths(); + resources = resources.filter((resource) => changedPaths.has(resource.getPath())); + } + if (resources.length === 0) { + return; + } const processedResources = await minifier({ resources, fs: fsInterface(workspace), diff --git a/packages/builder/lib/tasks/replaceBuildtime.js b/packages/builder/lib/tasks/replaceBuildtime.js index f4093c0b732..2a3ff1caf22 100644 --- a/packages/builder/lib/tasks/replaceBuildtime.js +++ b/packages/builder/lib/tasks/replaceBuildtime.js @@ -32,22 +32,24 @@ function getTimestamp() { * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @returns {Promise} Promise resolving with undefined once data has been written */ -export default function({workspace, options: {pattern}}) { - const timestamp = getTimestamp(); +export default async function({workspace, buildCache, options: {pattern}}) { + let resources = await workspace.byGlob(pattern); - return workspace.byGlob(pattern) - .then((processedResources) => { - return stringReplacer({ - resources: processedResources, - options: { - pattern: "${buildtime}", - replacement: timestamp - } - }); - }) - .then((processedResources) => { - return Promise.all(processedResources.map((resource) => { - return workspace.write(resource); - })); - }); + if (buildCache.hasCache()) { + const changedPaths = buildCache.getChangedProjectResourcePaths(); + resources = resources.filter((resource) => changedPaths.has(resource.getPath())); + } + const timestamp = getTimestamp(); + const processedResources = await stringReplacer({ + resources, + options: { + pattern: "${buildtime}", + replacement: timestamp + } + }); + return Promise.all(processedResources.map((resource) => { + if (resource) { + return workspace.write(resource); + } + })); } diff --git a/packages/builder/lib/tasks/replaceCopyright.js b/packages/builder/lib/tasks/replaceCopyright.js index 2ccb6a596df..09cd302d9f0 100644 --- a/packages/builder/lib/tasks/replaceCopyright.js +++ b/packages/builder/lib/tasks/replaceCopyright.js @@ -29,27 +29,30 @@ import stringReplacer from "../processors/stringReplacer.js"; * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @returns {Promise} Promise resolving with undefined once data has been written */ -export default function({workspace, options: {copyright, pattern}}) { +export default async function({workspace, buildCache, options: {copyright, pattern}}) { if (!copyright) { - return Promise.resolve(); + return; } // Replace optional placeholder ${currentYear} with the current year copyright = copyright.replace(/(?:\$\{currentYear\})/, new Date().getFullYear()); - return workspace.byGlob(pattern) - .then((processedResources) => { - return stringReplacer({ - resources: processedResources, - options: { - pattern: /(?:\$\{copyright\}|@copyright@)/g, - replacement: copyright - } - }); - }) - .then((processedResources) => { - return Promise.all(processedResources.map((resource) => { - return workspace.write(resource); - })); - }); + let resources = await workspace.byGlob(pattern); + if (buildCache.hasCache()) { + const changedPaths = buildCache.getChangedProjectResourcePaths(); + resources = resources.filter((resource) => changedPaths.has(resource.getPath())); + } + + const processedResources = await stringReplacer({ + resources, + options: { + pattern: /(?:\$\{copyright\}|@copyright@)/g, + replacement: copyright + } + }); + return Promise.all(processedResources.map((resource) => { + if (resource) { + return workspace.write(resource); + } + })); } diff --git a/packages/builder/lib/tasks/replaceVersion.js b/packages/builder/lib/tasks/replaceVersion.js index 699a6221a95..7d1a56ffed1 100644 --- a/packages/builder/lib/tasks/replaceVersion.js +++ b/packages/builder/lib/tasks/replaceVersion.js @@ -19,20 +19,23 @@ import stringReplacer from "../processors/stringReplacer.js"; * @param {string} parameters.options.version Replacement version * @returns {Promise} Promise resolving with undefined once data has been written */ -export default function({workspace, options: {pattern, version}}) { - return workspace.byGlob(pattern) - .then((allResources) => { - return stringReplacer({ - resources: allResources, - options: { - pattern: /\$\{(?:project\.)?version\}/g, - replacement: version - } - }); - }) - .then((processedResources) => { - return Promise.all(processedResources.map((resource) => { - return workspace.write(resource); - })); - }); +export default async function({workspace, buildCache, options: {pattern, version}}) { + let resources = await workspace.byGlob(pattern); + + if (buildCache.hasCache()) { + const changedPaths = buildCache.getChangedProjectResourcePaths(); + resources = resources.filter((resource) => changedPaths.has(resource.getPath())); + } + const processedResources = await stringReplacer({ + resources, + options: { + pattern: /\$\{(?:project\.)?version\}/g, + replacement: version + } + }); + await Promise.all(processedResources.map((resource) => { + if (resource) { + return workspace.write(resource); + } + })); } From c8401ee15f46526632a0cebc7a6008a7190aa263 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 15:55:28 +0100 Subject: [PATCH 004/223] refactor(project): Align getReader API internals with ComponentProjects Cherry-picked from: https://github.com/SAP/ui5-project/commit/82b20eea1fc5cec4026f8d077cc194408e34d9e7 JIRA: CPOUI5FOUNDATION-1174 --- .../lib/specifications/types/Module.js | 41 +++++++++------- .../lib/specifications/types/ThemeLibrary.js | 48 +++++++++++-------- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/packages/project/lib/specifications/types/Module.js b/packages/project/lib/specifications/types/Module.js index 69c5987c9d8..a0741f27491 100644 --- a/packages/project/lib/specifications/types/Module.js +++ b/packages/project/lib/specifications/types/Module.js @@ -69,25 +69,10 @@ class Module extends Project { // Apply builder excludes to all styles but "runtime" const excludes = style === "runtime" ? [] : this.getBuilderResourcesExcludes(); - const readers = this._paths.map(({name, virBasePath, fsBasePath}) => { - return resourceFactory.createReader({ - name, - virBasePath, - fsBasePath, - project: this, - excludes - }); - }); - if (readers.length === 1) { - return readers[0]; - } - const readerCollection = resourceFactory.createReaderCollection({ - name: `Reader collection for module project ${this.getName()}`, - readers - }); + const reader = this._getReader(excludes); return resourceFactory.createReaderCollectionPrioritized({ name: `Reader/Writer collection for project ${this.getName()}`, - readers: [this._getWriter(), readerCollection] + readers: [this._getWriter(), reader] }); } @@ -98,7 +83,8 @@ class Module extends Project { * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ getWorkspace() { - const reader = this.getReader(); + const excludes = this.getBuilderResourcesExcludes(); + const reader = this._getReader(excludes); const writer = this._getWriter(); return resourceFactory.createWorkspace({ @@ -107,6 +93,25 @@ class Module extends Project { }); } + _getReader(excludes) { + const readers = this._paths.map(({name, virBasePath, fsBasePath}) => { + return resourceFactory.createReader({ + name, + virBasePath, + fsBasePath, + project: this, + excludes + }); + }); + if (readers.length === 1) { + return readers[0]; + } + return resourceFactory.createReaderCollection({ + name: `Reader collection for module project ${this.getName()}`, + readers + }); + } + _getWriter() { if (!this._writer) { this._writer = resourceFactory.createAdapter({ diff --git a/packages/project/lib/specifications/types/ThemeLibrary.js b/packages/project/lib/specifications/types/ThemeLibrary.js index 398e570cdfb..51bf5a3ab4a 100644 --- a/packages/project/lib/specifications/types/ThemeLibrary.js +++ b/packages/project/lib/specifications/types/ThemeLibrary.js @@ -76,26 +76,7 @@ class ThemeLibrary extends Project { // Apply builder excludes to all styles but "runtime" const excludes = style === "runtime" ? [] : this.getBuilderResourcesExcludes(); - let reader = resourceFactory.createReader({ - fsBasePath: this.getSourcePath(), - virBasePath: "/resources/", - name: `Runtime resources reader for theme-library project ${this.getName()}`, - project: this, - excludes - }); - if (this._testPathExists) { - const testReader = resourceFactory.createReader({ - fsBasePath: fsPath.join(this.getRootPath(), this._testPath), - virBasePath: "/test-resources/", - name: `Runtime test-resources reader for theme-library project ${this.getName()}`, - project: this, - excludes - }); - reader = resourceFactory.createReaderCollection({ - name: `Reader collection for theme-library project ${this.getName()}`, - readers: [reader, testReader] - }); - } + const reader = this._getReader(excludes); const writer = this._getWriter(); return resourceFactory.createReaderCollectionPrioritized({ @@ -115,7 +96,8 @@ class ThemeLibrary extends Project { * @returns {@ui5/fs/DuplexCollection} DuplexCollection */ getWorkspace() { - const reader = this.getReader(); + const excludes = this.getBuilderResourcesExcludes(); + const reader = this._getReader(excludes); const writer = this._getWriter(); return resourceFactory.createWorkspace({ @@ -124,6 +106,30 @@ class ThemeLibrary extends Project { }); } + _getReader(excludes) { + let reader = resourceFactory.createReader({ + fsBasePath: this.getSourcePath(), + virBasePath: "/resources/", + name: `Runtime resources reader for theme-library project ${this.getName()}`, + project: this, + excludes + }); + if (this._testPathExists) { + const testReader = resourceFactory.createReader({ + fsBasePath: fsPath.join(this.getRootPath(), this._testPath), + virBasePath: "/test-resources/", + name: `Runtime test-resources reader for theme-library project ${this.getName()}`, + project: this, + excludes + }); + reader = resourceFactory.createReaderCollection({ + name: `Reader collection for theme-library project ${this.getName()}`, + readers: [reader, testReader] + }); + } + return reader; + } + _getWriter() { if (!this._writer) { this._writer = resourceFactory.createAdapter({ From 55721f65603b0ac33e0a00a8ca0a3700806b0176 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 15:56:27 +0100 Subject: [PATCH 005/223] refactor(project): Refactor specification-internal workspace handling Prerequisite for versioning support Cherry-picked from: https://github.com/SAP/ui5-project/commit/83b5c4f12dc545357e36366846ad1f6fe94a70e3 JIRA: CPOUI5FOUNDATION-1174 --- .../lib/specifications/ComponentProject.js | 132 +++++++----------- .../project/lib/specifications/Project.js | 89 ++++++++++-- .../lib/specifications/types/Module.js | 72 +++------- .../lib/specifications/types/ThemeLibrary.js | 83 +++-------- 4 files changed, 166 insertions(+), 210 deletions(-) diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index 337b3652e29..e40f8a9228b 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -91,39 +91,7 @@ class ComponentProject extends Project { /* === Resource Access === */ - /** - * Get a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for accessing all resources of the - * project in the specified "style": - * - *
    - *
  • buildtime: Resource paths are always prefixed with /resources/ - * or /test-resources/ followed by the project's namespace. - * Any configured build-excludes are applied
  • - *
  • dist: Resource paths always match with what the UI5 runtime expects. - * This means that paths generally depend on the project type. Applications for example use a "flat"-like - * structure, while libraries use a "buildtime"-like structure. - * Any configured build-excludes are applied
  • - *
  • runtime: Resource paths always match with what the UI5 runtime expects. - * This means that paths generally depend on the project type. Applications for example use a "flat"-like - * structure, while libraries use a "buildtime"-like structure. - * This style is typically used for serving resources directly. Therefore, build-excludes are not applied
  • - *
  • flat: Resource paths are never prefixed and namespaces are omitted if possible. Note that - * project types like "theme-library", which can have multiple namespaces, can't omit them. - * Any configured build-excludes are applied
  • - *
- * - * If project resources have been changed through the means of a workspace, those changes - * are reflected in the provided reader too. - * - * Resource readers always use POSIX-style paths. - * - * @public - * @param {object} [options] - * @param {string} [options.style=buildtime] Path style to access resources. - * Can be "buildtime", "dist", "runtime" or "flat" - * @returns {@ui5/fs/ReaderCollection} A reader collection instance - */ - getReader({style = "buildtime"} = {}) { + _getStyledReader(style) { // TODO: Additional style 'ABAP' using "sap.platform.abap".uri from manifest.json? // Apply builder excludes to all styles but "runtime" @@ -161,7 +129,7 @@ class ComponentProject extends Project { throw new Error(`Unknown path mapping style ${style}`); } - reader = this._addWriter(reader, style); + // reader = this._addWriter(reader, style, writer); return reader; } @@ -183,52 +151,49 @@ class ComponentProject extends Project { throw new Error(`_getTestReader must be implemented by subclass ${this.constructor.name}`); } - /** - * Get a resource reader/writer for accessing and modifying a project's resources - * - * @public - * @returns {@ui5/fs/ReaderCollection} A reader collection instance - */ - getWorkspace() { - // Workspace is always of style "buildtime" - // Therefore builder resource-excludes are always to be applied - const excludes = this.getBuilderResourcesExcludes(); - return resourceFactory.createWorkspace({ - name: `Workspace for project ${this.getName()}`, - reader: this._getReader(excludes), - writer: this._getWriter().collection - }); - } + // /** + // * Get a resource reader/writer for accessing and modifying a project's resources + // * + // * @public + // * @returns {@ui5/fs/ReaderCollection} A reader collection instance + // */ + // getWorkspace() { + // // Workspace is always of style "buildtime" + // // Therefore builder resource-excludes are always to be applied + // const excludes = this.getBuilderResourcesExcludes(); + // return resourceFactory.createWorkspace({ + // name: `Workspace for project ${this.getName()}`, + // reader: this._getPlainReader(excludes), + // writer: this._getWriter().collection + // }); + // } _getWriter() { - if (!this._writers) { - // writer is always of style "buildtime" - const namespaceWriter = resourceFactory.createAdapter({ - virBasePath: "/", - project: this - }); + // writer is always of style "buildtime" + const namespaceWriter = resourceFactory.createAdapter({ + virBasePath: "/", + project: this + }); - const generalWriter = resourceFactory.createAdapter({ - virBasePath: "/", - project: this - }); + const generalWriter = resourceFactory.createAdapter({ + virBasePath: "/", + project: this + }); - const collection = resourceFactory.createWriterCollection({ - name: `Writers for project ${this.getName()}`, - writerMapping: { - [`/resources/${this._namespace}/`]: namespaceWriter, - [`/test-resources/${this._namespace}/`]: namespaceWriter, - [`/`]: generalWriter - } - }); + const collection = resourceFactory.createWriterCollection({ + name: `Writers for project ${this.getName()}`, + writerMapping: { + [`/resources/${this._namespace}/`]: namespaceWriter, + [`/test-resources/${this._namespace}/`]: namespaceWriter, + [`/`]: generalWriter + } + }); - this._writers = { - namespaceWriter, - generalWriter, - collection - }; - } - return this._writers; + return { + namespaceWriter, + generalWriter, + collection + }; } _getReader(excludes) { @@ -243,15 +208,15 @@ class ComponentProject extends Project { return reader; } - _addWriter(reader, style) { - const {namespaceWriter, generalWriter} = this._getWriter(); + _addReadersFromWriter(style, readers, writer) { + const {namespaceWriter, generalWriter} = writer; if ((style === "runtime" || style === "dist") && this._isRuntimeNamespaced) { // If the project's type requires a namespace at runtime, the // dist- and runtime-style paths are identical to buildtime-style paths style = "buildtime"; } - const readers = []; + switch (style) { case "buildtime": // Writer already uses buildtime style @@ -279,12 +244,13 @@ class ComponentProject extends Project { default: throw new Error(`Unknown path mapping style ${style}`); } - readers.push(reader); + // return readers; + // readers.push(reader); - return resourceFactory.createReaderCollectionPrioritized({ - name: `Reader/Writer collection for project ${this.getName()}`, - readers - }); + // return resourceFactory.createReaderCollectionPrioritized({ + // name: `Reader/Writer collection for project ${this.getName()}`, + // readers + // }); } /* === Internals === */ diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 98cbf29da1d..9c7c7e00f6a 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -1,5 +1,6 @@ import Specification from "./Specification.js"; import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; +import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; /** * Project @@ -12,6 +13,12 @@ import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; * @hideconstructor */ class Project extends Specification { + #latestWriter; + #latestWorkspace; + #latestReader = new Map(); + #writerVersions = []; + #workspaceSealed = false; + constructor(parameters) { super(parameters); if (new.target === Project) { @@ -220,6 +227,7 @@ class Project extends Specification { } /* === Resource Access === */ + /** * Get a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for accessing all resources of the * project in the specified "style": @@ -241,38 +249,93 @@ class Project extends Specification { * Any configured build-excludes are applied * * + * If project resources have been changed through the means of a workspace, those changes + * are reflected in the provided reader too. + * * Resource readers always use POSIX-style paths. * * @public * @param {object} [options] * @param {string} [options.style=buildtime] Path style to access resources. * Can be "buildtime", "dist", "runtime" or "flat" - * @returns {@ui5/fs/ReaderCollection} Reader collection allowing access to all resources of the project + * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ - getReader(options) { - throw new Error(`getReader must be implemented by subclass ${this.constructor.name}`); + getReader({style = "buildtime"} = {}) { + let reader = this.#latestReader.get(style); + if (reader) { + return reader; + } + const readers = []; + this._addReadersFromWriter(style, readers, this.getWriter()); + readers.push(this._getStyledReader(style)); + reader = createReaderCollectionPrioritized({ + name: `Reader collection for project ${this.getName()}`, + readers + }); + this.#latestReader.set(style, reader); + return reader; } - getResourceTagCollection() { - if (!this._resourceTagCollection) { - this._resourceTagCollection = new ResourceTagCollection({ - allowedTags: ["ui5:IsDebugVariant", "ui5:HasDebugVariant"], - allowedNamespaces: ["project"], - tags: this.getBuildManifest()?.tags - }); - } - return this._resourceTagCollection; + getWriter() { + return this.#latestWriter || this.createNewWriterVersion(); + } + + createNewWriterVersion() { + const writer = this._getWriter(); + this.#writerVersions.push(writer); + this.#latestWriter = writer; + + // Invalidate dependents + this.#latestWorkspace = null; + this.#latestReader = new Map(); + + return writer; } /** * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a * project's resources. This is always of style buildtime. * + * Once a project has finished building, this method will throw to prevent further modifications + * since those would have no effect. Use the getReader method to access the project's (modified) resources + * * @public * @returns {@ui5/fs/DuplexCollection} DuplexCollection */ getWorkspace() { - throw new Error(`getWorkspace must be implemented by subclass ${this.constructor.name}`); + if (this.#workspaceSealed) { + throw new Error( + `Workspace of project ${this.getName()} has been sealed. Use method #getReader for read-only access`); + } + if (this.#latestWorkspace) { + return this.#latestWorkspace; + } + const excludes = this.getBuilderResourcesExcludes(); // TODO: Do not apply in server context + const writer = this.getWriter(); + this.#latestWorkspace = createWorkspace({ + reader: this._getReader(excludes), + writer: writer.collection || writer + }); + return this.#latestWorkspace; + } + + sealWorkspace() { + this.#workspaceSealed = true; + } + + _addReadersFromWriter(style, readers, writer) { + readers.push(writer); + } + + getResourceTagCollection() { + if (!this._resourceTagCollection) { + this._resourceTagCollection = new ResourceTagCollection({ + allowedTags: ["ui5:IsDebugVariant", "ui5:HasDebugVariant"], + allowedNamespaces: ["project"], + tags: this.getBuildManifest()?.tags + }); + } + return this._resourceTagCollection; } /* === Internals === */ diff --git a/packages/project/lib/specifications/types/Module.js b/packages/project/lib/specifications/types/Module.js index a0741f27491..a59c464f94a 100644 --- a/packages/project/lib/specifications/types/Module.js +++ b/packages/project/lib/specifications/types/Module.js @@ -33,65 +33,29 @@ class Module extends Project { /* === Resource Access === */ - /** - * Get a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for accessing all resources of the - * project in the specified "style": - * - *
    - *
  • buildtime: Resource paths are always prefixed with /resources/ - * or /test-resources/ followed by the project's namespace. - * Any configured build-excludes are applied
  • - *
  • dist: Resource paths always match with what the UI5 runtime expects. - * This means that paths generally depend on the project type. Applications for example use a "flat"-like - * structure, while libraries use a "buildtime"-like structure. - * Any configured build-excludes are applied
  • - *
  • runtime: Resource paths always match with what the UI5 runtime expects. - * This means that paths generally depend on the project type. Applications for example use a "flat"-like - * structure, while libraries use a "buildtime"-like structure. - * This style is typically used for serving resources directly. Therefore, build-excludes are not applied
  • - *
  • flat: Resource paths are never prefixed and namespaces are omitted if possible. Note that - * project types like "theme-library", which can have multiple namespaces, can't omit them. - * Any configured build-excludes are applied
  • - *
- * - * If project resources have been changed through the means of a workspace, those changes - * are reflected in the provided reader too. - * - * Resource readers always use POSIX-style paths. - * - * @public - * @param {object} [options] - * @param {string} [options.style=buildtime] Path style to access resources. - * Can be "buildtime", "dist", "runtime" or "flat" - * @returns {@ui5/fs/ReaderCollection} A reader collection instance - */ - getReader({style = "buildtime"} = {}) { + _getStyledReader(style) { // Apply builder excludes to all styles but "runtime" const excludes = style === "runtime" ? [] : this.getBuilderResourcesExcludes(); - const reader = this._getReader(excludes); - return resourceFactory.createReaderCollectionPrioritized({ - name: `Reader/Writer collection for project ${this.getName()}`, - readers: [this._getWriter(), reader] - }); + return this._getReader(excludes); } - /** - * Get a resource reader/writer for accessing and modifying a project's resources - * - * @public - * @returns {@ui5/fs/ReaderCollection} A reader collection instance - */ - getWorkspace() { - const excludes = this.getBuilderResourcesExcludes(); - const reader = this._getReader(excludes); - - const writer = this._getWriter(); - return resourceFactory.createWorkspace({ - reader, - writer - }); - } + // /** + // * Get a resource reader/writer for accessing and modifying a project's resources + // * + // * @public + // * @returns {@ui5/fs/ReaderCollection} A reader collection instance + // */ + // getWorkspace() { + // const excludes = this.getBuilderResourcesExcludes(); + // const reader = this._getReader(excludes); + + // const writer = this._getWriter(); + // return resourceFactory.createWorkspace({ + // reader, + // writer + // }); + // } _getReader(excludes) { const readers = this._paths.map(({name, virBasePath, fsBasePath}) => { diff --git a/packages/project/lib/specifications/types/ThemeLibrary.js b/packages/project/lib/specifications/types/ThemeLibrary.js index 51bf5a3ab4a..d4644c78885 100644 --- a/packages/project/lib/specifications/types/ThemeLibrary.js +++ b/packages/project/lib/specifications/types/ThemeLibrary.js @@ -40,71 +40,34 @@ class ThemeLibrary extends Project { } /* === Resource Access === */ - /** - * Get a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for accessing all resources of the - * project in the specified "style": - * - *
    - *
  • buildtime: Resource paths are always prefixed with /resources/ - * or /test-resources/ followed by the project's namespace. - * Any configured build-excludes are applied
  • - *
  • dist: Resource paths always match with what the UI5 runtime expects. - * This means that paths generally depend on the project type. Applications for example use a "flat"-like - * structure, while libraries use a "buildtime"-like structure. - * Any configured build-excludes are applied
  • - *
  • runtime: Resource paths always match with what the UI5 runtime expects. - * This means that paths generally depend on the project type. Applications for example use a "flat"-like - * structure, while libraries use a "buildtime"-like structure. - * This style is typically used for serving resources directly. Therefore, build-excludes are not applied
  • - *
  • flat: Resource paths are never prefixed and namespaces are omitted if possible. Note that - * project types like "theme-library", which can have multiple namespaces, can't omit them. - * Any configured build-excludes are applied
  • - *
- * - * If project resources have been changed through the means of a workspace, those changes - * are reflected in the provided reader too. - * - * Resource readers always use POSIX-style paths. - * - * @public - * @param {object} [options] - * @param {string} [options.style=buildtime] Path style to access resources. - * Can be "buildtime", "dist", "runtime" or "flat" - * @returns {@ui5/fs/ReaderCollection} A reader collection instance - */ - getReader({style = "buildtime"} = {}) { + + _getStyledReader(style) { // Apply builder excludes to all styles but "runtime" const excludes = style === "runtime" ? [] : this.getBuilderResourcesExcludes(); - const reader = this._getReader(excludes); - const writer = this._getWriter(); - - return resourceFactory.createReaderCollectionPrioritized({ - name: `Reader/Writer collection for project ${this.getName()}`, - readers: [writer, reader] - }); + return this._getReader(excludes); } - /** - * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a - * project's resources. - * - * This is always of style buildtime, wich for theme libraries is identical to style - * runtime. - * - * @public - * @returns {@ui5/fs/DuplexCollection} DuplexCollection - */ - getWorkspace() { - const excludes = this.getBuilderResourcesExcludes(); - const reader = this._getReader(excludes); - - const writer = this._getWriter(); - return resourceFactory.createWorkspace({ - reader, - writer - }); - } + // /** + // * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a + // * project's resources. + // * + // * This is always of style buildtime, which for theme libraries is identical to style + // * runtime. + // * + // * @public + // * @returns {@ui5/fs/DuplexCollection} DuplexCollection + // */ + // getWorkspace() { + // const excludes = this.getBuilderResourcesExcludes(); + // const reader = this._getReader(excludes); + + // const writer = this._getWriter(); + // return resourceFactory.createWorkspace({ + // reader, + // writer + // }); + // } _getReader(excludes) { let reader = resourceFactory.createReader({ From 088e951eb406719088be111d6476e66ca3104ae8 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 15:57:13 +0100 Subject: [PATCH 006/223] refactor(project): Implement basic incremental build functionality Cherry-picked from: https://github.com/SAP/ui5-project/commit/cb4e858a630fe673cdaf3f991fa8fb6272e45ea2 JIRA: CPOUI5FOUNDATION-1174 --- packages/project/lib/build/ProjectBuilder.js | 144 +++++- packages/project/lib/build/TaskRunner.js | 63 ++- .../project/lib/build/cache/BuildTaskCache.js | 193 ++++++++ .../lib/build/cache/ProjectBuildCache.js | 433 ++++++++++++++++++ .../project/lib/build/helpers/BuildContext.js | 39 +- .../lib/build/helpers/ProjectBuildContext.js | 110 ++++- .../project/lib/build/helpers/WatchHandler.js | 135 ++++++ .../lib/build/helpers/createBuildManifest.js | 89 +++- packages/project/lib/graph/ProjectGraph.js | 88 +++- .../lib/specifications/ComponentProject.js | 17 +- .../project/lib/specifications/Project.js | 291 ++++++++++-- .../lib/specifications/types/Application.js | 25 +- .../lib/specifications/types/Library.js | 43 +- .../lib/specifications/types/Module.js | 10 +- .../lib/specifications/types/ThemeLibrary.js | 23 +- .../project/test/lib/build/ProjectBuilder.js | 2 + packages/project/test/lib/build/TaskRunner.js | 174 +++---- .../lib/build/helpers/ProjectBuildContext.js | 6 +- 18 files changed, 1695 insertions(+), 190 deletions(-) create mode 100644 packages/project/lib/build/cache/BuildTaskCache.js create mode 100644 packages/project/lib/build/cache/ProjectBuildCache.js create mode 100644 packages/project/lib/build/helpers/WatchHandler.js diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 4a805d8a385..88e92cd75e4 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -139,9 +139,11 @@ class ProjectBuilder { async build({ destPath, cleanDest = false, includedDependencies = [], excludedDependencies = [], - dependencyIncludes + dependencyIncludes, + cacheDir, + watch, }) { - if (!destPath) { + if (!destPath && !watch) { throw new Error(`Missing parameter 'destPath'`); } if (dependencyIncludes) { @@ -177,12 +179,15 @@ class ProjectBuilder { } } - const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects); + const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects, cacheDir); const cleanupSigHooks = this._registerCleanupSigHooks(); - const fsTarget = resourceFactory.createAdapter({ - fsBasePath: destPath, - virBasePath: "/" - }); + let fsTarget; + if (destPath) { + fsTarget = resourceFactory.createAdapter({ + fsBasePath: destPath, + virBasePath: "/" + }); + } const queue = []; const alreadyBuilt = []; @@ -196,7 +201,7 @@ class ProjectBuilder { // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); - if (!projectBuildContext.requiresBuild()) { + if (!await projectBuildContext.requiresBuild()) { alreadyBuilt.push(projectName); } } @@ -220,8 +225,12 @@ class ProjectBuilder { let msg; if (alreadyBuilt.includes(projectName)) { const buildMetadata = projectBuildContext.getBuildMetadata(); - const ts = new Date(buildMetadata.timestamp).toUTCString(); - msg = `*> ${projectName} /// already built at ${ts}`; + let buildAt = ""; + if (buildMetadata) { + const ts = new Date(buildMetadata.timestamp).toUTCString(); + buildAt = ` at ${ts}`; + } + msg = `*> ${projectName} /// already built${buildAt}`; } else { msg = `=> ${projectName}`; } @@ -231,7 +240,7 @@ class ProjectBuilder { } } - if (cleanDest) { + if (destPath && cleanDest) { this.#log.info(`Cleaning target directory...`); await rmrf(destPath); } @@ -239,8 +248,9 @@ class ProjectBuilder { try { const pWrites = []; for (const projectBuildContext of queue) { - const projectName = projectBuildContext.getProject().getName(); - const projectType = projectBuildContext.getProject().getType(); + const project = projectBuildContext.getProject(); + const projectName = project.getName(); + const projectType = project.getType(); this.#log.verbose(`Processing project ${projectName}...`); // Only build projects that are not already build (i.e. provide a matching build manifest) @@ -248,7 +258,9 @@ class ProjectBuilder { this.#log.skipProjectBuild(projectName, projectType); } else { this.#log.startProjectBuild(projectName, projectType); + project.newVersion(); await projectBuildContext.getTaskRunner().runTasks(); + project.sealWorkspace(); this.#log.endProjectBuild(projectName, projectType); } if (!requestedProjects.includes(projectName) || !!process.env.UI5_BUILD_NO_WRITE_DEST) { @@ -257,8 +269,15 @@ class ProjectBuilder { continue; } - this.#log.verbose(`Writing out files...`); - pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + if (fsTarget) { + this.#log.verbose(`Writing out files...`); + pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + } + + if (cacheDir && !alreadyBuilt.includes(projectName)) { + this.#log.verbose(`Serializing cache...`); + pWrites.push(projectBuildContext.getBuildCache().serializeToDisk()); + } } await Promise.all(pWrites); this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); @@ -269,9 +288,91 @@ class ProjectBuilder { this._deregisterCleanupSigHooks(cleanupSigHooks); await this._executeCleanupTasks(); } + + if (watch) { + const relevantProjects = queue.map((projectBuildContext) => { + return projectBuildContext.getProject(); + }); + const watchHandler = this._buildContext.initWatchHandler(relevantProjects, async () => { + await this.#update(projectBuildContexts, requestedProjects, fsTarget, cacheDir); + }); + return watchHandler; + + // Register change handler + // this._buildContext.onSourceFileChange(async (event) => { + // await this.#update(projectBuildContexts, requestedProjects, + // fsTarget, + // targetWriterProject, targetWriterDependencies); + // updateOnChange(event); + // }, (err) => { + // updateOnChange(err); + // }); + + // // Start watching + // for (const projectBuildContext of queue) { + // await projectBuildContext.watchFileChanges(); + // } + } + } + + async #update(projectBuildContexts, requestedProjects, fsTarget, cacheDir) { + const queue = []; + await this._graph.traverseDepthFirst(async ({project}) => { + const projectName = project.getName(); + const projectBuildContext = projectBuildContexts.get(projectName); + if (projectBuildContext) { + // Build context exists + // => This project needs to be built or, in case it has already + // been built, it's build result needs to be written out (if requested) + // if (await projectBuildContext.requiresBuild()) { + queue.push(projectBuildContext); + // } + } + }); + + this.#log.setProjects(queue.map((projectBuildContext) => { + return projectBuildContext.getProject().getName(); + })); + + const pWrites = []; + for (const projectBuildContext of queue) { + const project = projectBuildContext.getProject(); + const projectName = project.getName(); + const projectType = project.getType(); + this.#log.verbose(`Updating project ${projectName}...`); + + if (!await projectBuildContext.requiresBuild()) { + this.#log.skipProjectBuild(projectName, projectType); + continue; + } + + this.#log.startProjectBuild(projectName, projectType); + project.newVersion(); + await projectBuildContext.runTasks(); + project.sealWorkspace(); + this.#log.endProjectBuild(projectName, projectType); + if (!requestedProjects.includes(projectName)) { + // Project has not been requested + // => Its resources shall not be part of the build result + continue; + } + + if (fsTarget) { + this.#log.verbose(`Writing out files...`); + pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + } + + if (cacheDir) { + this.#log.verbose(`Updating cache...`); + // TODO: Only serialize if cache has changed + // TODO: Serialize lazily, or based on memory pressure + pWrites.push(projectBuildContext.getBuildCache().serializeToDisk()); + } + } + await Promise.all(pWrites); } - async _createRequiredBuildContexts(requestedProjects) { + async _createRequiredBuildContexts(requestedProjects, cacheDir) { const requiredProjects = new Set(this._graph.getProjectNames().filter((projectName) => { return requestedProjects.includes(projectName); })); @@ -280,13 +381,14 @@ class ProjectBuilder { for (const projectName of requiredProjects) { this.#log.verbose(`Creating build context for project ${projectName}...`); - const projectBuildContext = this._buildContext.createProjectContext({ - project: this._graph.getProject(projectName) + const projectBuildContext = await this._buildContext.createProjectContext({ + project: this._graph.getProject(projectName), + cacheDir, }); projectBuildContexts.set(projectName, projectBuildContext); - if (projectBuildContext.requiresBuild()) { + if (await projectBuildContext.requiresBuild()) { const taskRunner = projectBuildContext.getTaskRunner(); const requiredDependencies = await taskRunner.getRequiredDependencies(); @@ -389,7 +491,9 @@ class ProjectBuilder { const { default: createBuildManifest } = await import("./helpers/createBuildManifest.js"); - const metadata = await createBuildManifest(project, buildConfig, this._buildContext.getTaskRepository()); + const metadata = await createBuildManifest( + project, this._graph, buildConfig, this._buildContext.getTaskRepository(), + projectBuildContext.getBuildCache()); await target.write(resourceFactory.createResource({ path: `/.ui5/build-manifest.json`, string: JSON.stringify(metadata, null, "\t") diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 0f5677170a3..08473e39b2b 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -1,6 +1,6 @@ import {getLogger} from "@ui5/logger"; import composeTaskList from "./helpers/composeTaskList.js"; -import {createReaderCollection} from "@ui5/fs/resourceFactory"; +import {createReaderCollection, createTracker} from "@ui5/fs/resourceFactory"; /** * TaskRunner @@ -21,8 +21,8 @@ class TaskRunner { * @param {@ui5/project/build/ProjectBuilder~BuildConfiguration} parameters.buildConfig * Build configuration */ - constructor({graph, project, log, taskUtil, taskRepository, buildConfig}) { - if (!graph || !project || !log || !taskUtil || !taskRepository || !buildConfig) { + constructor({graph, project, log, cache, taskUtil, taskRepository, buildConfig}) { + if (!graph || !project || !log || !cache || !taskUtil || !taskRepository || !buildConfig) { throw new Error("TaskRunner: One or more mandatory parameters not provided"); } this._project = project; @@ -31,6 +31,7 @@ class TaskRunner { this._taskRepository = taskRepository; this._buildConfig = buildConfig; this._log = log; + this._cache = cache; this._directDependencies = new Set(this._taskUtil.getDependencies()); } @@ -190,20 +191,62 @@ class TaskRunner { options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); + // TODO: Apply cache and stage handling for custom tasks as well + this._project.useStage(taskName); + + // Check whether any of the relevant resources have changed + if (this._cache.hasCacheForTask(taskName)) { + await this._cache.validateChangedProjectResources( + taskName, this._project.getReader(), this._allDependenciesReader); + if (this._cache.hasValidCacheForTask(taskName)) { + this._log.skipTask(taskName); + return; + } + } + this._log.info( + `Executing task ${taskName} for project ${this._project.getName()}`); + const workspace = createTracker(this._project.getWorkspace()); const params = { - workspace: this._project.getWorkspace(), + workspace, taskUtil: this._taskUtil, - options + options, + buildCache: { + // TODO: Create a proper interface for this + hasCache: () => { + return this._cache.hasCacheForTask(taskName); + }, + getChangedProjectResourcePaths: () => { + return this._cache.getChangedProjectResourcePaths(taskName); + }, + getChangedDependencyResourcePaths: () => { + return this._cache.getChangedDependencyResourcePaths(taskName); + }, + } }; + // const invalidatedResources = this._cache.getDepsOfInvalidatedResourcesForTask(taskName); + // if (invalidatedResources) { + // params.invalidatedResources = invalidatedResources; + // } + let dependencies; if (requiresDependencies) { - params.dependencies = this._allDependenciesReader; + dependencies = createTracker(this._allDependenciesReader); + params.dependencies = dependencies; } if (!taskFunction) { taskFunction = (await this._taskRepository.getTask(taskName)).task; } - return taskFunction(params); + + this._log.startTask(taskName); + this._taskStart = performance.now(); + await taskFunction(params); + if (this._log.isLevelEnabled("perf")) { + this._log.perf( + `Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); + } + this._log.endTask(taskName); + await this._cache.updateTaskResult(taskName, workspace, dependencies); }; } this._tasks[taskName] = { @@ -445,13 +488,15 @@ class TaskRunner { * @returns {Promise} Resolves when task has finished */ async _executeTask(taskName, taskFunction, taskParams) { - this._log.startTask(taskName); + if (this._cache.hasValidCacheForTask(taskName)) { + this._log.skipTask(taskName); + return; + } this._taskStart = performance.now(); await taskFunction(taskParams, this._log); if (this._log.isLevelEnabled("perf")) { this._log.perf(`Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); } - this._log.endTask(taskName); } async _createDependenciesReader(requiredDirectDependencies) { diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js new file mode 100644 index 00000000000..1927b33e58c --- /dev/null +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -0,0 +1,193 @@ +import micromatch from "micromatch"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:cache:BuildTaskCache"); + +function unionArray(arr, items) { + for (const item of items) { + if (!arr.includes(item)) { + arr.push(item); + } + } +} +function unionObject(target, obj) { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + target[key] = obj[key]; + } + } +} + +async function createMetadataForResources(resourceMap) { + const metadata = Object.create(null); + await Promise.all(Object.keys(resourceMap).map(async (resourcePath) => { + const resource = resourceMap[resourcePath]; + if (resource.hash) { + // Metadata object + metadata[resourcePath] = resource; + return; + } + // Resource instance + metadata[resourcePath] = { + hash: await resource.getHash(), + lastModified: resource.getStatInfo()?.mtimeMs, + }; + })); + return metadata; +} + +export default class BuildTaskCache { + #projectName; + #taskName; + + // Track which resource paths (and patterns) the task reads + // This is used to check whether a resource change *might* invalidates the task + #projectRequests; + #dependencyRequests; + + // Track metadata for the actual resources the task has read and written + // This is used to check whether a resource has actually changed from the last time the task has been executed (and + // its result has been cached) + // Per resource path, this reflects the last known state of the resource (a task might be executed multiple times, + // i.e. with a small delta of changed resources) + // This map can contain either a resource instance (if the cache has been filled during this session) or an object + // containing the last modified timestamp and an md5 hash of the resource (if the cache has been loaded from disk) + #resourcesRead; + #resourcesWritten; + + constructor(projectName, taskName, {projectRequests, dependencyRequests, resourcesRead, resourcesWritten}) { + this.#projectName = projectName; + this.#taskName = taskName; + + this.#projectRequests = projectRequests ?? { + pathsRead: [], + patterns: [], + }; + + this.#dependencyRequests = dependencyRequests ?? { + pathsRead: [], + patterns: [], + }; + this.#resourcesRead = resourcesRead ?? Object.create(null); + this.#resourcesWritten = resourcesWritten ?? Object.create(null); + } + + getTaskName() { + return this.#taskName; + } + + updateResources(projectRequests, dependencyRequests, resourcesRead, resourcesWritten) { + unionArray(this.#projectRequests.pathsRead, projectRequests.pathsRead); + unionArray(this.#projectRequests.patterns, projectRequests.patterns); + + if (dependencyRequests) { + unionArray(this.#dependencyRequests.pathsRead, dependencyRequests.pathsRead); + unionArray(this.#dependencyRequests.patterns, dependencyRequests.patterns); + } + + unionObject(this.#resourcesRead, resourcesRead); + unionObject(this.#resourcesWritten, resourcesWritten); + } + + async toObject() { + return { + taskName: this.#taskName, + resourceMetadata: { + projectRequests: this.#projectRequests, + dependencyRequests: this.#dependencyRequests, + resourcesRead: await createMetadataForResources(this.#resourcesRead), + resourcesWritten: await createMetadataForResources(this.#resourcesWritten) + } + }; + } + + checkPossiblyInvalidatesTask(projectResourcePaths, dependencyResourcePaths) { + if (this.#isRelevantResourceChange(this.#projectRequests, projectResourcePaths)) { + log.verbose( + `Build cache for task ${this.#taskName} of project ${this.#projectName} possibly invalidated ` + + `by changes made to the following resources ${Array.from(projectResourcePaths).join(", ")}`); + return true; + } + + if (this.#isRelevantResourceChange(this.#dependencyRequests, dependencyResourcePaths)) { + log.verbose( + `Build cache for task ${this.#taskName} of project ${this.#projectName} possibly invalidated ` + + `by changes made to the following resources: ${Array.from(dependencyResourcePaths).join(", ")}`); + return true; + } + + return false; + } + + getReadResourceCacheEntry(searchResourcePath) { + return this.#resourcesRead[searchResourcePath]; + } + + getWrittenResourceCache(searchResourcePath) { + return this.#resourcesWritten[searchResourcePath]; + } + + async isResourceInReadCache(resource) { + const cachedResource = this.#resourcesRead[resource.getPath()]; + if (!cachedResource) { + return false; + } + if (cachedResource.hash) { + return this.#isResourceFingerprintEqual(resource, cachedResource); + } else { + return this.#isResourceEqual(resource, cachedResource); + } + } + + async isResourceInWriteCache(resource) { + const cachedResource = this.#resourcesWritten[resource.getPath()]; + if (!cachedResource) { + return false; + } + if (cachedResource.hash) { + return this.#isResourceFingerprintEqual(resource, cachedResource); + } else { + return this.#isResourceEqual(resource, cachedResource); + } + } + + async #isResourceEqual(resourceA, resourceB) { + if (!resourceA || !resourceB) { + throw new Error("Cannot compare undefined resources"); + } + if (resourceA === resourceB) { + return true; + } + if (resourceA.getStatInfo()?.mtimeMs !== resourceA.getStatInfo()?.mtimeMs) { + return false; + } + if (await resourceA.getString() === await resourceB.getString()) { + return true; + } + return false; + } + + async #isResourceFingerprintEqual(resourceA, resourceBMetadata) { + if (!resourceA || !resourceBMetadata) { + throw new Error("Cannot compare undefined resources"); + } + if (resourceA.getStatInfo()?.mtimeMs !== resourceBMetadata.lastModified) { + return false; + } + if (await resourceA.getHash() === resourceBMetadata.hash) { + return true; + } + return false; + } + + #isRelevantResourceChange({pathsRead, patterns}, changedResourcePaths) { + for (const resourcePath of changedResourcePaths) { + if (pathsRead.includes(resourcePath)) { + return true; + } + if (patterns.length && micromatch.isMatch(resourcePath, patterns)) { + return true; + } + } + return false; + } +} diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js new file mode 100644 index 00000000000..3fc87b06afe --- /dev/null +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -0,0 +1,433 @@ +import path from "node:path"; +import {stat} from "node:fs/promises"; +import {createResource, createAdapter} from "@ui5/fs/resourceFactory"; +import {getLogger} from "@ui5/logger"; +import BuildTaskCache from "./BuildTaskCache.js"; +const log = getLogger("build:cache:ProjectBuildCache"); + +/** + * A project's build cache can have multiple states + * - Initial build without existing build manifest or cache: + * * No build manifest + * * Tasks are unknown + * * Resources are unknown + * * No persistence of workspaces + * - Build of project with build manifest + * * (a valid build manifest implies that the project will not be built initially) + * * Tasks are known + * * Resources required and produced by tasks are known + * * No persistence of workspaces + * * => In case of a rebuild, all tasks need to be executed once to restore the workspaces + * - Build of project with build manifest and cache + * * Tasks are known + * * Resources required and produced by tasks are known + * * Workspaces can be restored from cache + */ + +export default class ProjectBuildCache { + #taskCache = new Map(); + #project; + #cacheKey; + #cacheDir; + #cacheRoot; + + #invalidatedTasks = new Map(); + #updatedResources = new Set(); + #restoreFailed = false; + + /** + * + * @param {Project} project Project instance + * @param {string} cacheKey Cache key + * @param {string} [cacheDir] Cache directory + */ + constructor(project, cacheKey, cacheDir) { + this.#project = project; + this.#cacheKey = cacheKey; + this.#cacheDir = cacheDir; + this.#cacheRoot = cacheDir && createAdapter({ + fsBasePath: cacheDir, + virBasePath: "/" + }); + } + + async updateTaskResult(taskName, workspaceTracker, dependencyTracker) { + const projectTrackingResults = workspaceTracker.getResults(); + const dependencyTrackingResults = dependencyTracker?.getResults(); + + const resourcesRead = projectTrackingResults.resourcesRead; + if (dependencyTrackingResults) { + for (const [resourcePath, resource] of Object.entries(dependencyTrackingResults.resourcesRead)) { + resourcesRead[resourcePath] = resource; + } + } + const resourcesWritten = projectTrackingResults.resourcesWritten; + + if (this.#taskCache.has(taskName)) { + log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); + const taskCache = this.#taskCache.get(taskName); + + const writtenResourcePaths = Object.keys(resourcesWritten); + if (writtenResourcePaths.length) { + log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); + + const changedPaths = new Set((await Promise.all(writtenResourcePaths + .map(async (resourcePath) => { + // Check whether resource content actually changed + if (await taskCache.isResourceInWriteCache(resourcesWritten[resourcePath])) { + return undefined; + } + return resourcePath; + }))).filter((resourcePath) => resourcePath !== undefined)); + + if (!changedPaths.size) { + log.verbose( + `Resources produced by task ${taskName} match with cache from previous executions. ` + + `This task will not invalidate any other tasks`); + return; + } + log.verbose( + `Task ${taskName} produced ${changedPaths.size} resources that might invalidate other tasks`); + for (const resourcePath of changedPaths) { + this.#updatedResources.add(resourcePath); + } + // Check whether other tasks need to be invalidated + const allTasks = Array.from(this.#taskCache.keys()); + const taskIndex = allTasks.indexOf(taskName); + const emptySet = new Set(); + for (let i = taskIndex + 1; i < allTasks.length; i++) { + const nextTaskName = allTasks[i]; + if (!this.#taskCache.get(nextTaskName).checkPossiblyInvalidatesTask(changedPaths, emptySet)) { + continue; + } + if (this.#invalidatedTasks.has(taskName)) { + const {changedDependencyResourcePaths} = + this.#invalidatedTasks.get(taskName); + for (const resourcePath of changedPaths) { + changedDependencyResourcePaths.add(resourcePath); + } + } else { + this.#invalidatedTasks.set(taskName, { + changedProjectResourcePaths: changedPaths, + changedDependencyResourcePaths: emptySet + }); + } + } + } + taskCache.updateResources( + projectTrackingResults.requests, + dependencyTrackingResults?.requests, + resourcesRead, + resourcesWritten + ); + } else { + log.verbose(`Initializing build cache for task ${taskName} in project ${this.#project.getName()}`); + this.#taskCache.set(taskName, + new BuildTaskCache(this.#project.getName(), taskName, { + projectRequests: projectTrackingResults.requests, + dependencyRequests: dependencyTrackingResults?.requests, + resourcesRead, + resourcesWritten + }) + ); + } + + if (this.#invalidatedTasks.has(taskName)) { + this.#invalidatedTasks.delete(taskName); + } + } + + harvestUpdatedResources() { + const updatedResources = new Set(this.#updatedResources); + this.#updatedResources.clear(); + return updatedResources; + } + + resourceChanged(projectResourcePaths, dependencyResourcePaths) { + let taskInvalidated = false; + for (const [taskName, taskCache] of this.#taskCache) { + if (!taskCache.checkPossiblyInvalidatesTask(projectResourcePaths, dependencyResourcePaths)) { + continue; + } + taskInvalidated = true; + if (this.#invalidatedTasks.has(taskName)) { + const {changedProjectResourcePaths, changedDependencyResourcePaths} = + this.#invalidatedTasks.get(taskName); + for (const resourcePath of projectResourcePaths) { + changedProjectResourcePaths.add(resourcePath); + } + for (const resourcePath of dependencyResourcePaths) { + changedDependencyResourcePaths.add(resourcePath); + } + } else { + this.#invalidatedTasks.set(taskName, { + changedProjectResourcePaths: new Set(projectResourcePaths), + changedDependencyResourcePaths: new Set(dependencyResourcePaths) + }); + } + } + return taskInvalidated; + } + + async validateChangedProjectResources(taskName, workspace, dependencies) { + // Check whether the supposedly changed resources for the task have actually changed + if (!this.#invalidatedTasks.has(taskName)) { + return; + } + const {changedProjectResourcePaths, changedDependencyResourcePaths} = this.#invalidatedTasks.get(taskName); + await this._validateChangedResources(taskName, workspace, changedProjectResourcePaths); + await this._validateChangedResources(taskName, dependencies, changedDependencyResourcePaths); + + if (!changedProjectResourcePaths.size && !changedDependencyResourcePaths.size) { + // Task is no longer invalidated + this.#invalidatedTasks.delete(taskName); + } + } + + async _validateChangedResources(taskName, reader, changedResourcePaths) { + for (const resourcePath of changedResourcePaths) { + const resource = await reader.byPath(resourcePath); + if (!resource) { + // Resource was deleted, no need to check further + continue; + } + + const taskCache = this.#taskCache.get(taskName); + if (!taskCache) { + throw new Error(`Failed to validate changed resources for task ${taskName}: Task cache not found`); + } + if (await taskCache.isResourceInReadCache(resource)) { + log.verbose(`Resource content has not changed for task ${taskName}, ` + + `removing ${resourcePath} from set of changed resource paths`); + changedResourcePaths.delete(resourcePath); + } + } + } + + getChangedProjectResourcePaths(taskName) { + return this.#invalidatedTasks.get(taskName)?.changedProjectResourcePaths ?? new Set(); + } + + getChangedDependencyResourcePaths(taskName) { + return this.#invalidatedTasks.get(taskName)?.changedDependencyResourcePaths ?? new Set(); + } + + hasCache() { + return this.#taskCache.size > 0; + } + + /* + Check whether the project's build cache has an entry for the given stage. + This means that the cache has been filled with the output of the given stage. + */ + hasCacheForTask(taskName) { + return this.#taskCache.has(taskName); + } + + hasValidCacheForTask(taskName) { + return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName); + } + + getCacheForTask(taskName) { + return this.#taskCache.get(taskName); + } + + requiresBuild() { + return !this.hasCache() || this.#invalidatedTasks.size > 0; + } + + async toObject() { + // const globalResourceIndex = Object.create(null); + // function addResourcesToIndex(taskName, resourceMap) { + // for (const resourcePath of Object.keys(resourceMap)) { + // const resource = resourceMap[resourcePath]; + // const resourceKey = `${resourcePath}:${resource.hash}`; + // if (!globalResourceIndex[resourceKey]) { + // globalResourceIndex[resourceKey] = { + // hash: resource.hash, + // lastModified: resource.lastModified, + // tasks: [taskName] + // }; + // } else if (!globalResourceIndex[resourceKey].tasks.includes(taskName)) { + // globalResourceIndex[resourceKey].tasks.push(taskName); + // } + // } + // } + const taskCache = []; + for (const cache of this.#taskCache.values()) { + const cacheObject = await cache.toObject(); + taskCache.push(cacheObject); + // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesRead); + // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesWritten); + // addResourcesToIndex(taskName, cacheObject.resources.dependencies.resourcesRead); + } + // Collect metadata for all relevant source files + const sourceReader = this.#project.getSourceReader(); + // const resourceMetadata = await Promise.all(Array.from(relevantSourceFiles).map(async (resourcePath) => { + const resources = await sourceReader.byGlob("/**/*"); + const sourceMetadata = Object.create(null); + await Promise.all(resources.map(async (resource) => { + sourceMetadata[resource.getOriginalPath()] = { + lastModified: resource.getStatInfo()?.mtimeMs, + hash: await resource.getHash(), + }; + })); + + return { + timestamp: Date.now(), + cacheKey: this.#cacheKey, + taskCache, + sourceMetadata, + // globalResourceIndex, + }; + } + + async #serializeMetadata() { + const serializedCache = await this.toObject(); + const cacheContent = JSON.stringify(serializedCache, null, 2); + const res = createResource({ + path: `/cache-info.json`, + string: cacheContent, + }); + await this.#cacheRoot.write(res); + } + + async #serializeTaskOutputs() { + log.info(`Serializing task outputs for project ${this.#project.getName()}`); + const stageCache = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { + const reader = this.#project.getDeltaReader(taskName); + if (!reader) { + log.verbose( + `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` + ); + return; + } + const resources = await reader.byGlob("/**/*"); + + const target = createAdapter({ + fsBasePath: path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`), + virBasePath: "/" + }); + + for (const res of resources) { + await target.write(res); + } + return { + reader: target, + stage: taskName + }; + })); + // Re-import cache as base layer to reduce memory pressure + this.#project.importCachedStages(stageCache.filter((entry) => entry)); + } + + async #checkSourceChanges(sourceMetadata) { + log.verbose(`Checking for source changes for project ${this.#project.getName()}`); + const sourceReader = this.#project.getSourceReader(); + const resources = await sourceReader.byGlob("/**/*"); + const changedResources = new Set(); + for (const resource of resources) { + const resourcePath = resource.getOriginalPath(); + const resourceMetadata = sourceMetadata[resourcePath]; + if (!resourceMetadata) { + // New resource + log.verbose(`New resource: ${resourcePath}`); + changedResources.add(resourcePath); + continue; + } + if (resourceMetadata.lastModified !== resource.getStatInfo()?.mtimeMs) { + log.verbose(`Resource changed: ${resourcePath}`); + changedResources.add(resourcePath); + } + // TODO: Hash-based check can be requested by user and per project + // The performance impact can be quite high for large projects + /* + if (someFlag) { + const currentHash = await resource.getHash(); + if (currentHash !== resourceMetadata.hash) { + log.verbose(`Resource changed: ${resourcePath}`); + changedResources.add(resourcePath); + } + }*/ + } + if (changedResources.size) { + const tasksInvalidated = this.resourceChanged(changedResources, new Set()); + if (tasksInvalidated) { + log.info(`Invalidating tasks due to changed resources for project ${this.#project.getName()}`); + } + } + } + + async #deserializeWriter() { + const cachedStages = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { + const fsBasePath = path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`); + let cacheReader; + if (await exists(fsBasePath)) { + cacheReader = createAdapter({ + name: `Cache reader for task ${taskName} in project ${this.#project.getName()}`, + fsBasePath, + virBasePath: "/", + project: this.#project, + }); + } + + return { + stage: taskName, + reader: cacheReader + }; + })); + this.#project.importCachedStages(cachedStages); + } + + async serializeToDisk() { + if (!this.#cacheRoot) { + log.error("Cannot save cache to disk: No cache persistence available"); + return; + } + await Promise.all([ + await this.#serializeTaskOutputs(), + await this.#serializeMetadata() + ]); + } + + async attemptDeserializationFromDisk() { + if (this.#restoreFailed || !this.#cacheRoot) { + return; + } + const res = await this.#cacheRoot.byPath(`/cache-info.json`); + if (!res) { + this.#restoreFailed = true; + return; + } + const cacheContent = JSON.parse(await res.getString()); + try { + const projectName = this.#project.getName(); + for (const {taskName, resourceMetadata} of cacheContent.taskCache) { + this.#taskCache.set(taskName, new BuildTaskCache(projectName, taskName, resourceMetadata)); + } + await Promise.all([ + this.#checkSourceChanges(cacheContent.sourceMetadata), + this.#deserializeWriter() + ]); + } catch (err) { + throw new Error( + `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { + cause: err + }); + } + } +} + +async function exists(filePath) { + try { + await stat(filePath); + return true; + } catch (err) { + // "File or directory does not exist" + if (err.code === "ENOENT") { + return false; + } else { + throw err; + } + } +} diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 8d8d1e1a329..063aaf30e21 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -1,5 +1,8 @@ +import path from "node:path"; import ProjectBuildContext from "./ProjectBuildContext.js"; import OutputStyleEnum from "./ProjectBuilderOutputStyle.js"; +import {createCacheKey} from "./createBuildManifest.js"; +import WatchHandler from "./WatchHandler.js"; /** * Context of a build process @@ -8,11 +11,14 @@ import OutputStyleEnum from "./ProjectBuilderOutputStyle.js"; * @memberof @ui5/project/build/helpers */ class BuildContext { + #watchHandler; + constructor(graph, taskRepository, { // buildConfig selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, + useCache = false, outputStyle = OutputStyleEnum.Default, includedTasks = [], excludedTasks = [], } = {}) { @@ -67,6 +73,7 @@ class BuildContext { outputStyle, includedTasks, excludedTasks, + useCache, }; this._taskRepository = taskRepository; @@ -97,15 +104,43 @@ class BuildContext { return this._graph; } - createProjectContext({project}) { + async createProjectContext({project, cacheDir}) { + const cacheKey = await this.#createCacheKeyForProject(project); + if (cacheDir) { + cacheDir = path.join(cacheDir, cacheKey); + } const projectBuildContext = new ProjectBuildContext({ buildContext: this, - project + project, + cacheKey, + cacheDir, }); this._projectBuildContexts.push(projectBuildContext); return projectBuildContext; } + initWatchHandler(projects, updateBuildResult) { + const watchHandler = new WatchHandler(this, updateBuildResult); + watchHandler.watch(projects); + this.#watchHandler = watchHandler; + return watchHandler; + } + + getWatchHandler() { + return this.#watchHandler; + } + + async #createCacheKeyForProject(project) { + return createCacheKey(project, this._graph, + this.getBuildConfig(), this.getTaskRepository()); + } + + getBuildContext(projectName) { + if (projectName) { + return this._projectBuildContexts.find((ctx) => ctx.getProject().getName() === projectName); + } + } + async executeCleanupTasks(force = false) { await Promise.all(this._projectBuildContexts.map((ctx) => { return ctx.executeCleanupTasks(force); diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 10eb2a67a83..20a9e668150 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -2,6 +2,7 @@ import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; import ProjectBuildLogger from "@ui5/logger/internal/loggers/ProjectBuild"; import TaskUtil from "./TaskUtil.js"; import TaskRunner from "../TaskRunner.js"; +import ProjectBuildCache from "../cache/ProjectBuildCache.js"; /** * Build context of a single project. Always part of an overall @@ -11,7 +12,16 @@ import TaskRunner from "../TaskRunner.js"; * @memberof @ui5/project/build/helpers */ class ProjectBuildContext { - constructor({buildContext, project}) { + /** + * + * @param {object} parameters Parameters + * @param {object} parameters.buildContext The build context. + * @param {object} parameters.project The project instance. + * @param {string} parameters.cacheKey The cache key. + * @param {string} parameters.cacheDir The cache directory. + * @throws {Error} Throws an error if 'buildContext' or 'project' is missing. + */ + constructor({buildContext, project, cacheKey, cacheDir}) { if (!buildContext) { throw new Error(`Missing parameter 'buildContext'`); } @@ -25,6 +35,8 @@ class ProjectBuildContext { projectName: project.getName(), projectType: project.getType() }); + this._cacheKey = cacheKey; + this._cache = new ProjectBuildCache(this._project, cacheKey, cacheDir); this._queues = { cleanup: [] }; @@ -33,6 +45,10 @@ class ProjectBuildContext { allowedTags: ["ui5:OmitFromBuildResult", "ui5:IsBundle"], allowedNamespaces: ["build"] }); + const buildManifest = this.#getBuildManifest(); + if (buildManifest) { + this._cache.deserialize(buildManifest.buildManifest.cache); + } } isRootProject() { @@ -111,6 +127,7 @@ class ProjectBuildContext { this._taskRunner = new TaskRunner({ project: this._project, log: this._log, + cache: this._cache, taskUtil: this.getTaskUtil(), graph: this._buildContext.getGraph(), taskRepository: this._buildContext.getTaskRepository(), @@ -126,23 +143,106 @@ class ProjectBuildContext { * * @returns {boolean} True if the project needs to be built */ - requiresBuild() { - return !this._project.getBuildManifest(); + async requiresBuild() { + if (this.#getBuildManifest()) { + return false; + } + + if (!this._cache.hasCache()) { + await this._cache.attemptDeserializationFromDisk(); + } + + return this._cache.requiresBuild(); + } + + async runTasks() { + await this.getTaskRunner().runTasks(); + const updatedResourcePaths = this._cache.harvestUpdatedResources(); + + if (updatedResourcePaths.size === 0) { + return; + } + this._log.verbose( + `Project ${this._project.getName()} updated resources: ${Array.from(updatedResourcePaths).join(", ")}`); + const graph = this._buildContext.getGraph(); + const emptySet = new Set(); + + // Propagate changes to all dependents of the project + for (const {project: dep} of graph.traverseDependents(this._project.getName())) { + const projectBuildContext = this._buildContext.getBuildContext(dep.getName()); + projectBuildContext.getBuildCache().resourceChanged(emptySet, updatedResourcePaths); + } + } + + #getBuildManifest() { + const manifest = this._project.getBuildManifest(); + if (!manifest) { + return; + } + // Check whether the manifest can be used for this build + if (manifest.buildManifest.manifestVersion === "0.1" || manifest.buildManifest.manifestVersion === "0.2") { + // Manifest version 0.1 and 0.2 are always used without further checks for legacy reasons + return manifest; + } + if (manifest.buildManifest.manifestVersion === "0.3" && + manifest.buildManifest.cacheKey === this.getCacheKey()) { + // Manifest version 0.3 is used with a matching cache key + return manifest; + } + // Unknown manifest version can't be used + return; } getBuildMetadata() { - const buildManifest = this._project.getBuildManifest(); + const buildManifest = this.#getBuildManifest(); if (!buildManifest) { return null; } const timeDiff = (new Date().getTime() - new Date(buildManifest.timestamp).getTime()); - // TODO: Format age properly via a new @ui5/logger util module + // TODO: Format age properly return { timestamp: buildManifest.timestamp, age: timeDiff / 1000 + " seconds" }; } + + getBuildCache() { + return this._cache; + } + + getCacheKey() { + return this._cacheKey; + } + + // async watchFileChanges() { + // // const paths = this._project.getSourcePaths(); + // // this._log.verbose(`Watching source paths: ${paths.join(", ")}`); + // // const {default: chokidar} = await import("chokidar"); + // // const watcher = chokidar.watch(paths, { + // // ignoreInitial: true, + // // persistent: false, + // // }); + // // watcher.on("add", async (filePath) => { + // // }); + // // watcher.on("change", async (filePath) => { + // // const resourcePath = this._project.getVirtualPath(filePath); + // // this._log.info(`File changed: ${resourcePath} (${filePath})`); + // // // Inform cache + // // this._cache.fileChanged(resourcePath); + // // // Inform dependents + // // for (const dependent of this._buildContext.getGraph().getTransitiveDependents(this._project.getName())) { + // // await this._buildContext.getProjectBuildContext(dependent).dependencyFileChanged(resourcePath); + // // } + // // // Inform build context + // // await this._buildContext.fileChanged(this._project.getName(), resourcePath); + // // }); + // } + + // dependencyFileChanged(resourcePath) { + // this._log.info(`Dependency file changed: ${resourcePath}`); + // this._cache.fileChanged(resourcePath); + // } } export default ProjectBuildContext; diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js new file mode 100644 index 00000000000..0a5510a7eba --- /dev/null +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -0,0 +1,135 @@ +import EventEmitter from "node:events"; +import path from "node:path"; +import {watch} from "node:fs/promises"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:helpers:WatchHandler"); + +/** + * Context of a build process + * + * @private + * @memberof @ui5/project/build/helpers + */ +class WatchHandler extends EventEmitter { + #buildContext; + #updateBuildResult; + #abortControllers = []; + #sourceChanges = new Map(); + #fileChangeHandlerTimeout; + + constructor(buildContext, updateBuildResult) { + super(); + this.#buildContext = buildContext; + this.#updateBuildResult = updateBuildResult; + } + + watch(projects) { + for (const project of projects) { + const paths = project.getSourcePaths(); + log.verbose(`Watching source paths: ${paths.join(", ")}`); + + for (const sourceDir of paths) { + const ac = new AbortController(); + const watcher = watch(sourceDir, { + persistent: true, + recursive: true, + signal: ac.signal, + }); + + this.#abortControllers.push(ac); + this.#handleWatchEvents(watcher, sourceDir, project); // Do not await as this would block the loop + } + } + } + + stop() { + for (const ac of this.#abortControllers) { + ac.abort(); + } + } + + async #handleWatchEvents(watcher, basePath, project) { + try { + for await (const {eventType, filename} of watcher) { + log.verbose(`File changed: ${eventType} ${filename}`); + if (filename) { + await this.#fileChanged(project, path.join(basePath, filename.toString())); + } + } + } catch (err) { + if (err.name === "AbortError") { + return; + } + throw err; + } + } + + async #fileChanged(project, filePath) { + // Collect changes (grouped by project), then trigger callbacks (debounced) + const resourcePath = project.getVirtualPath(filePath); + if (!this.#sourceChanges.has(project)) { + this.#sourceChanges.set(project, new Set()); + } + this.#sourceChanges.get(project).add(resourcePath); + + // Trigger callbacks debounced + if (!this.#fileChangeHandlerTimeout) { + this.#fileChangeHandlerTimeout = setTimeout(async () => { + await this.#handleResourceChanges(); + this.#fileChangeHandlerTimeout = null; + }, 100); + } else { + clearTimeout(this.#fileChangeHandlerTimeout); + this.#fileChangeHandlerTimeout = setTimeout(async () => { + await this.#handleResourceChanges(); + this.#fileChangeHandlerTimeout = null; + }, 100); + } + } + + async #handleResourceChanges() { + // Reset file changes before processing + const sourceChanges = this.#sourceChanges; + this.#sourceChanges = new Map(); + const dependencyChanges = new Map(); + let someProjectTasksInvalidated = false; + + const graph = this.#buildContext.getGraph(); + for (const [project, changedResourcePaths] of sourceChanges) { + // Propagate changes to dependents of the project + for (const {project: dep} of graph.traverseDependents(project.getName())) { + const depChanges = dependencyChanges.get(dep); + if (!depChanges) { + dependencyChanges.set(dep, new Set(changedResourcePaths)); + continue; + } + for (const res of changedResourcePaths) { + depChanges.add(res); + } + } + } + + await graph.traverseDepthFirst(({project}) => { + if (!sourceChanges.has(project) && !dependencyChanges.has(project)) { + return; + } + const projectSourceChanges = sourceChanges.get(project) ?? new Set(); + const projectDependencyChanges = dependencyChanges.get(project) ?? new Set(); + const projectBuildContext = this.#buildContext.getBuildContext(project.getName()); + const tasksInvalidated = + projectBuildContext.getBuildCache().resourceChanged(projectSourceChanges, projectDependencyChanges); + + if (tasksInvalidated) { + someProjectTasksInvalidated = true; + } + }); + + if (someProjectTasksInvalidated) { + this.emit("projectInvalidated"); + await this.#updateBuildResult(); + this.emit("buildUpdated"); + } + } +} + +export default WatchHandler; diff --git a/packages/project/lib/build/helpers/createBuildManifest.js b/packages/project/lib/build/helpers/createBuildManifest.js index 998935b3c05..ba19023d54f 100644 --- a/packages/project/lib/build/helpers/createBuildManifest.js +++ b/packages/project/lib/build/helpers/createBuildManifest.js @@ -1,4 +1,5 @@ import {createRequire} from "node:module"; +import crypto from "node:crypto"; // Using CommonsJS require since JSON module imports are still experimental const require = createRequire(import.meta.url); @@ -16,16 +17,33 @@ function getSortedTags(project) { return Object.fromEntries(entities); } -export default async function(project, buildConfig, taskRepository) { +async function collectDepInfo(graph, project) { + const transitiveDependencyInfo = Object.create(null); + for (const depName of graph.getTransitiveDependencies(project.getName())) { + const dep = graph.getProject(depName); + transitiveDependencyInfo[depName] = { + version: dep.getVersion() + }; + } + return transitiveDependencyInfo; +} + +export default async function(project, graph, buildConfig, taskRepository, transitiveDependencyInfo, buildCache) { if (!project) { throw new Error(`Missing parameter 'project'`); } + if (!graph) { + throw new Error(`Missing parameter 'graph'`); + } if (!buildConfig) { throw new Error(`Missing parameter 'buildConfig'`); } if (!taskRepository) { throw new Error(`Missing parameter 'taskRepository'`); } + if (!buildCache) { + throw new Error(`Missing parameter 'buildCache'`); + } const projectName = project.getName(); const type = project.getType(); @@ -44,8 +62,21 @@ export default async function(project, buildConfig, taskRepository) { `Unable to create archive metadata for project ${project.getName()}: ` + `Project type ${type} is currently not supported`); } + let buildManifest; + if (project.isFrameworkProject()) { + buildManifest = await createFrameworkManifest(project, buildConfig, taskRepository); + } else { + buildManifest = { + manifestVersion: "0.3", + timestamp: new Date().toISOString(), + dependencies: collectDepInfo(graph, project), + version: project.getVersion(), + namespace: project.getNamespace(), + tags: getSortedTags(project), + cacheKey: createCacheKey(project, graph, buildConfig, taskRepository), + }; + } - const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); const metadata = { project: { specVersion: project.getSpecVersion().toString(), @@ -59,27 +90,49 @@ export default async function(project, buildConfig, taskRepository) { } } }, - buildManifest: { - manifestVersion: "0.2", - timestamp: new Date().toISOString(), - versions: { - builderVersion: builderVersion, - projectVersion: await getVersion("@ui5/project"), - fsVersion: await getVersion("@ui5/fs"), - }, - buildConfig, - version: project.getVersion(), - namespace: project.getNamespace(), - tags: getSortedTags(project) - } + buildManifest, + buildCache: await buildCache.serialize(), }; - if (metadata.buildManifest.versions.fsVersion !== builderFsVersion) { + return metadata; +} + +async function createFrameworkManifest(project, buildConfig, taskRepository) { + // Use legacy manifest version for framework libraries to ensure compatibility + const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); + const buildManifest = { + manifestVersion: "0.2", + timestamp: new Date().toISOString(), + versions: { + builderVersion: builderVersion, + projectVersion: await getVersion("@ui5/project"), + fsVersion: await getVersion("@ui5/fs"), + }, + buildConfig, + version: project.getVersion(), + namespace: project.getNamespace(), + tags: getSortedTags(project) + }; + + if (buildManifest.versions.fsVersion !== builderFsVersion) { // Added in manifestVersion 0.2: // @ui5/project and @ui5/builder use different versions of @ui5/fs. // This should be mentioned in the build manifest: - metadata.buildManifest.versions.builderFsVersion = builderFsVersion; + buildManifest.versions.builderFsVersion = builderFsVersion; } + return buildManifest; +} - return metadata; +export async function createCacheKey(project, graph, buildConfig, taskRepository) { + const depInfo = collectDepInfo(graph, project); + const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); + const projectVersion = await getVersion("@ui5/project"); + const fsVersion = await getVersion("@ui5/fs"); + + const key = `${builderVersion}-${projectVersion}-${fsVersion}-${builderFsVersion}-` + + `${JSON.stringify(buildConfig)}-${JSON.stringify(depInfo)}`; + const hash = crypto.createHash("sha256").update(key).digest("hex"); + + // Create a hash from the cache key + return `${project.getName()}-${project.getVersion()}-${hash}`; } diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index ba6967154e6..0d15174e3b3 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -284,6 +284,40 @@ class ProjectGraph { processDependency(projectName); return Array.from(dependencies); } + + getDependents(projectName) { + if (!this._projects.has(projectName)) { + throw new Error( + `Failed to get dependents for project ${projectName}: ` + + `Unable to find project in project graph`); + } + const dependents = []; + for (const [fromProjectName, adjacencies] of this._adjList) { + if (adjacencies.has(projectName)) { + dependents.push(fromProjectName); + } + } + return dependents; + } + + getTransitiveDependents(projectName) { + const dependents = new Set(); + if (!this._projects.has(projectName)) { + throw new Error( + `Failed to get transitive dependents for project ${projectName}: ` + + `Unable to find project in project graph`); + } + const addDependents = (projectName) => { + const projectDependents = this.getDependents(projectName); + projectDependents.forEach((dependent) => { + dependents.add(dependent); + addDependents(dependent); + }); + }; + addDependents(projectName); + return Array.from(dependents); + } + /** * Checks whether a dependency is optional or not. * Currently only used in tests. @@ -475,6 +509,54 @@ class ProjectGraph { })(); } + * traverseDependents(startName, includeStartModule = false) { + if (typeof startName === "boolean") { + includeStartModule = startName; + startName = undefined; + } + if (!startName) { + startName = this._rootProjectName; + } else if (!this.getProject(startName)) { + throw new Error(`Failed to start graph traversal: Could not find project ${startName} in project graph`); + } + + const queue = [{ + projectNames: [startName], + ancestors: [] + }]; + + const visited = Object.create(null); + + while (queue.length) { + const {projectNames, ancestors} = queue.shift(); // Get and remove first entry from queue + + for (const projectName of projectNames) { + this._checkCycle(ancestors, projectName); + if (visited[projectName]) { + continue; + } + + visited[projectName] = true; + + const newAncestors = [...ancestors, projectName]; + const dependents = this.getDependents(projectName); + + queue.push({ + projectNames: dependents, + ancestors: newAncestors + }); + + if (includeStartModule || projectName !== startName) { + // Do not yield the start module itself + yield { + project: this.getProject(projectName), + dependents + }; + } + } + } + } + /** * Join another project graph into this one. * Projects and extensions which already exist in this graph will cause an error to be thrown @@ -558,7 +640,8 @@ class ProjectGraph { dependencyIncludes, selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], - outputStyle = OutputStyleEnum.Default + outputStyle = OutputStyleEnum.Default, + cacheDir, watch, }) { this.seal(); // Do not allow further changes to the graph if (this._built) { @@ -579,10 +662,11 @@ class ProjectGraph { includedTasks, excludedTasks, outputStyle, } }); - await builder.build({ + return await builder.build({ destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, + cacheDir, watch, }); } diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index e40f8a9228b..34e1fd852ba 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -164,24 +164,26 @@ class ComponentProject extends Project { // return resourceFactory.createWorkspace({ // name: `Workspace for project ${this.getName()}`, // reader: this._getPlainReader(excludes), - // writer: this._getWriter().collection + // writer: this._createWriter().collection // }); // } - _getWriter() { + _createWriter() { // writer is always of style "buildtime" const namespaceWriter = resourceFactory.createAdapter({ + name: `Namespace writer for project ${this.getName()} (${this.getCurrentStage()} stage)`, virBasePath: "/", project: this }); const generalWriter = resourceFactory.createAdapter({ + name: `General writer for project ${this.getName()} (${this.getCurrentStage()} stage)`, virBasePath: "/", project: this }); const collection = resourceFactory.createWriterCollection({ - name: `Writers for project ${this.getName()}`, + name: `Writers for project ${this.getName()} (${this.getCurrentStage()} stage)`, writerMapping: { [`/resources/${this._namespace}/`]: namespaceWriter, [`/test-resources/${this._namespace}/`]: namespaceWriter, @@ -208,8 +210,13 @@ class ComponentProject extends Project { return reader; } - _addReadersFromWriter(style, readers, writer) { - const {namespaceWriter, generalWriter} = writer; + _addWriterToReaders(style, readers, writer) { + let {namespaceWriter, generalWriter} = writer; + if (!namespaceWriter || !generalWriter) { + // TODO: Too hacky + namespaceWriter = writer; + generalWriter = writer; + } if ((style === "runtime" || style === "dist") && this._isRuntimeNamespaced) { // If the project's type requires a namespace at runtime, the diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 9c7c7e00f6a..5cdd01e6629 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -13,11 +13,15 @@ import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resour * @hideconstructor */ class Project extends Specification { - #latestWriter; - #latestWorkspace; - #latestReader = new Map(); - #writerVersions = []; - #workspaceSealed = false; + #currentWriter; + #currentWorkspace; + #currentReader = new Map(); + #currentStage; + #currentVersion = 0; // Writer version (0 is reserved for a possible imported writer cache) + + #stages = [""]; // Stages in order of creation + #writers = new Map(); // Maps stage to a set of writer versions (possibly sparse array) + #workspaceSealed = true; // Project starts as being sealed. Needs to be unsealed using newVersion() constructor(parameters) { super(parameters); @@ -94,6 +98,14 @@ class Project extends Specification { throw new Error(`getSourcePath must be implemented by subclass ${this.constructor.name}`); } + getSourcePaths() { + throw new Error(`getSourcePaths must be implemented by subclass ${this.constructor.name}`); + } + + getVirtualPath() { + throw new Error(`getVirtualPath must be implemented by subclass ${this.constructor.name}`); + } + /** * Get the project's framework name configuration * @@ -261,37 +273,68 @@ class Project extends Specification { * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ getReader({style = "buildtime"} = {}) { - let reader = this.#latestReader.get(style); + let reader = this.#currentReader.get(style); if (reader) { + // Use cached reader return reader; } - const readers = []; - this._addReadersFromWriter(style, readers, this.getWriter()); - readers.push(this._getStyledReader(style)); - reader = createReaderCollectionPrioritized({ - name: `Reader collection for project ${this.getName()}`, - readers - }); - this.#latestReader.set(style, reader); + // const readers = []; + // this._addWriterToReaders(style, readers, this.getWriter()); + // readers.push(this._getStyledReader(style)); + // reader = createReaderCollectionPrioritized({ + // name: `Reader collection for project ${this.getName()}`, + // readers + // }); + reader = this.#getReader(this.#currentStage, this.#currentVersion, style); + this.#currentReader.set(style, reader); return reader; } - getWriter() { - return this.#latestWriter || this.createNewWriterVersion(); + // getCacheReader({style = "buildtime"} = {}) { + // return this.#getReader(this.#currentStage, style, true); + // } + + getSourceReader(style = "buildtime") { + return this._getStyledReader(style); } - createNewWriterVersion() { - const writer = this._getWriter(); - this.#writerVersions.push(writer); - this.#latestWriter = writer; + #getWriter() { + if (this.#currentWriter) { + return this.#currentWriter; + } + + const stage = this.#currentStage; + const currentVersion = this.#currentVersion; - // Invalidate dependents - this.#latestWorkspace = null; - this.#latestReader = new Map(); + if (!this.#writers.has(stage)) { + this.#writers.set(stage, []); + } + const versions = this.#writers.get(stage); + let writer; + if (versions[currentVersion]) { + writer = versions[currentVersion]; + } else { + // Create new writer + writer = this._createWriter(); + versions[currentVersion] = writer; + } + this.#currentWriter = writer; return writer; } + // #createNewWriterStage(stageId) { + // const writer = this._createWriter(); + // this.#writers.set(stageId, writer); + // this.#currentWriter = writer; + + // // Invalidate dependents + // this.#currentWorkspace = null; + // this.#currentReader = new Map(); + + // return writer; + // } + /** * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a * project's resources. This is always of style buildtime. @@ -305,25 +348,209 @@ class Project extends Specification { getWorkspace() { if (this.#workspaceSealed) { throw new Error( - `Workspace of project ${this.getName()} has been sealed. Use method #getReader for read-only access`); + `Workspace of project ${this.getName()} has been sealed. This indicates that the project already ` + + `finished building and its content must not be modified further. ` + + `Use method 'getReader' for read-only access`); } - if (this.#latestWorkspace) { - return this.#latestWorkspace; + if (this.#currentWorkspace) { + return this.#currentWorkspace; } - const excludes = this.getBuilderResourcesExcludes(); // TODO: Do not apply in server context - const writer = this.getWriter(); - this.#latestWorkspace = createWorkspace({ - reader: this._getReader(excludes), + const writer = this.#getWriter(); + + // if (this.#stageCacheReaders.has(this.getCurrentStage())) { + // reader = createReaderCollectionPrioritized({ + // name: `Reader collection for project ${this.getName()} stage ${this.getCurrentStage()}`, + // readers: [ + // this.#stageCacheReaders.get(this.getCurrentStage()), + // reader, + // ] + // }); + // } + this.#currentWorkspace = createWorkspace({ + reader: this.getReader(), writer: writer.collection || writer }); - return this.#latestWorkspace; + return this.#currentWorkspace; } + // getWorkspaceForVersion(version) { + // return createWorkspace({ + // reader: this.#getReader(version), + // writer: this.#writerVersions[version].collection || this.#writerVersions[version] + // }); + // } + sealWorkspace() { this.#workspaceSealed = true; + this.useFinalStage(); + } + + newVersion() { + this.#workspaceSealed = false; + this.#currentVersion++; + this.useInitialStage(); + } + + revertToLastVersion() { + if (this.#currentVersion === 0) { + throw new Error(`Unable to revert to previous version: No previous version available`); + } + this.#currentVersion--; + this.useInitialStage(); + + // Remove writer version from all stages + for (const writerVersions of this.#writers.values()) { + if (writerVersions[this.#currentVersion]) { + delete writerVersions[this.#currentVersion]; + } + } + } + + #getReader(stage, version, style = "buildtime") { + const readers = []; + + // Add writers for previous stages as readers + const stageIdx = this.#stages.indexOf(stage); + if (stageIdx > 0) { // Stage 0 has no previous stage + // Collect writers from all preceding stages + for (let i = stageIdx - 1; i >= 0; i--) { + const stageWriters = this.#getWriters(this.#stages[i], version, style); + if (stageWriters) { + readers.push(stageWriters); + } + } + } + + // Always add source reader + readers.push(this._getStyledReader(style)); + + return createReaderCollectionPrioritized({ + name: `Reader collection for stage '${stage}' of project ${this.getName()}`, + readers: readers + }); + } + + useStage(stageId, newWriter = false) { + // if (newWriter && this.#writers.has(stageId)) { + // this.#writers.delete(stageId); + // } + if (stageId === this.#currentStage) { + return; + } + if (!this.#stages.includes(stageId)) { + // Add new stage + this.#stages.push(stageId); + } + + this.#currentStage = stageId; + + // Unset "current" reader/writer + this.#currentReader = new Map(); + this.#currentWriter = null; + this.#currentWorkspace = null; + } + + useInitialStage() { + this.useStage(""); + } + + useFinalStage() { + this.useStage(""); + } + + #getWriters(stage, version, style = "buildtime") { + const readers = []; + const stageWriters = this.#writers.get(stage); + if (!stageWriters?.length) { + return null; + } + for (let i = version; i >= 0; i--) { + if (!stageWriters[i]) { + // Writers is a sparse array, some stages might skip a version + continue; + } + this._addWriterToReaders(style, readers, stageWriters[i]); + } + + return createReaderCollectionPrioritized({ + name: `Collection of all writers for stage '${stage}', version ${version} of project ${this.getName()}`, + readers + }); + } + + getDeltaReader(stage) { + const readers = []; + const stageWriters = this.#writers.get(stage); + if (!stageWriters?.length) { + return null; + } + const version = this.#currentVersion; + for (let i = version; i >= 1; i--) { // Skip version 0 (possibly containing cached writers) + if (!stageWriters[i]) { + // Writers is a sparse array, some stages might skip a version + continue; + } + this._addWriterToReaders("buildtime", readers, stageWriters[i]); + } + + const reader = createReaderCollectionPrioritized({ + name: `Collection of new writers for stage '${stage}', version ${version} of project ${this.getName()}`, + readers + }); + + + // Condense writer versions (TODO: this step is optional but might improve memory consumption) + // this.#condenseVersions(reader); + return reader; + } + + // #condenseVersions(reader) { + // for (const stage of this.#stages) { + // const stageWriters = this.#writers.get(stage); + // if (!stageWriters) { + // continue; + // } + // const condensedWriter = this._createWriter(); + + // for (let i = 1; i < stageWriters.length; i++) { + // if (stageWriters[i]) { + + // } + // } + + // // eslint-disable-next-line no-sparse-arrays + // const newWriters = [, condensedWriter]; + // if (stageWriters[0]) { + // newWriters[0] = stageWriters[0]; + // } + // this.#writers.set(stage, newWriters); + // } + // } + + importCachedStages(stages) { + if (!this.#workspaceSealed) { + throw new Error(`Unable to import cached stages: Workspace is not sealed`); + } + for (const {stage, reader} of stages) { + if (!this.#stages.includes(stage)) { + this.#stages.push(stage); + } + if (reader) { + this.#writers.set(stage, [reader]); + } else { + this.#writers.set(stage, []); + } + } + this.#currentVersion = 0; + this.useFinalStage(); + } + + getCurrentStage() { + return this.#currentStage; } - _addReadersFromWriter(style, readers, writer) { + /* Overwritten in ComponentProject subclass */ + _addWriterToReaders(style, readers, writer) { readers.push(writer); } diff --git a/packages/project/lib/specifications/types/Application.js b/packages/project/lib/specifications/types/Application.js index 1dc17b4bc1c..44f39b4ef6d 100644 --- a/packages/project/lib/specifications/types/Application.js +++ b/packages/project/lib/specifications/types/Application.js @@ -45,6 +45,21 @@ class Application extends ComponentProject { return fsPath.join(this.getRootPath(), this._webappPath); } + getSourcePaths() { + return [this.getSourcePath()]; + } + + getVirtualPath(sourceFilePath) { + const sourcePath = this.getSourcePath(); + if (sourceFilePath.startsWith(sourcePath)) { + const relSourceFilePath = fsPath.relative(sourcePath, sourceFilePath); + return `/resources/${this._namespace}/${relSourceFilePath}`; + } + + throw new Error( + `Unable to convert source path ${sourceFilePath} to virtual path for project ${this.getName()}`); + } + /* === Resource Access === */ /** * Get a resource reader for the sources of the project (excluding any test resources) @@ -107,13 +122,13 @@ class Application extends ComponentProject { /** * @private * @param {object} config Configuration object - * @param {object} buildDescription Cache metadata object + * @param {object} buildManifest Cache metadata object */ - async _parseConfiguration(config, buildDescription) { - await super._parseConfiguration(config, buildDescription); + async _parseConfiguration(config, buildManifest) { + await super._parseConfiguration(config, buildManifest); - if (buildDescription) { - this._namespace = buildDescription.namespace; + if (buildManifest) { + this._namespace = buildManifest.namespace; return; } this._namespace = await this._getNamespace(); diff --git a/packages/project/lib/specifications/types/Library.js b/packages/project/lib/specifications/types/Library.js index d3d2059a055..e118f39e6b6 100644 --- a/packages/project/lib/specifications/types/Library.js +++ b/packages/project/lib/specifications/types/Library.js @@ -56,6 +56,39 @@ class Library extends ComponentProject { return fsPath.join(this.getRootPath(), this._srcPath); } + getSourcePaths() { + const paths = [this.getSourcePath()]; + if (this._testPathExists) { + paths.push(fsPath.join(this.getRootPath(), this._testPath)); + } + return paths; + } + + getVirtualPath(sourceFilePath) { + const sourcePath = this.getSourcePath(); + if (sourceFilePath.startsWith(sourcePath)) { + const relSourceFilePath = fsPath.relative(sourcePath, sourceFilePath); + let virBasePath = "/resources/"; + if (!this._isSourceNamespaced) { + virBasePath += `${this._namespace}/`; + } + return posixPath.join(virBasePath, relSourceFilePath); + } + + const testPath = fsPath.join(this.getRootPath(), this._testPath); + if (sourceFilePath.startsWith(testPath)) { + const relSourceFilePath = fsPath.relative(testPath, sourceFilePath); + let virBasePath = "/test-resources/"; + if (!this._isSourceNamespaced) { + virBasePath += `${this._namespace}/`; + } + return posixPath.join(virBasePath, relSourceFilePath); + } + + throw new Error( + `Unable to convert source path ${sourceFilePath} to virtual path for project ${this.getName()}`); + } + /* === Resource Access === */ /** * Get a resource reader for the sources of the project (excluding any test resources) @@ -156,13 +189,13 @@ class Library extends ComponentProject { /** * @private * @param {object} config Configuration object - * @param {object} buildDescription Cache metadata object + * @param {object} buildManifest Cache metadata object */ - async _parseConfiguration(config, buildDescription) { - await super._parseConfiguration(config, buildDescription); + async _parseConfiguration(config, buildManifest) { + await super._parseConfiguration(config, buildManifest); - if (buildDescription) { - this._namespace = buildDescription.namespace; + if (buildManifest) { + this._namespace = buildManifest.namespace; return; } diff --git a/packages/project/lib/specifications/types/Module.js b/packages/project/lib/specifications/types/Module.js index a59c464f94a..dcd3a9a2176 100644 --- a/packages/project/lib/specifications/types/Module.js +++ b/packages/project/lib/specifications/types/Module.js @@ -31,6 +31,12 @@ class Module extends Project { throw new Error(`Projects of type module have more than one source path`); } + getSourcePaths() { + return this._paths.map(({fsBasePath}) => { + return fsBasePath; + }); + } + /* === Resource Access === */ _getStyledReader(style) { @@ -50,7 +56,7 @@ class Module extends Project { // const excludes = this.getBuilderResourcesExcludes(); // const reader = this._getReader(excludes); - // const writer = this._getWriter(); + // const writer = this._createWriter(); // return resourceFactory.createWorkspace({ // reader, // writer @@ -76,7 +82,7 @@ class Module extends Project { }); } - _getWriter() { + _createWriter() { if (!this._writer) { this._writer = resourceFactory.createAdapter({ virBasePath: "/" diff --git a/packages/project/lib/specifications/types/ThemeLibrary.js b/packages/project/lib/specifications/types/ThemeLibrary.js index d4644c78885..9412975721e 100644 --- a/packages/project/lib/specifications/types/ThemeLibrary.js +++ b/packages/project/lib/specifications/types/ThemeLibrary.js @@ -39,6 +39,25 @@ class ThemeLibrary extends Project { return fsPath.join(this.getRootPath(), this._srcPath); } + getSourcePaths() { + return [this.getSourcePath()]; + } + + getVirtualPath(sourceFilePath) { + const sourcePath = this.getSourcePath(); + if (sourceFilePath.startsWith(sourcePath)) { + const relSourceFilePath = fsPath.relative(sourcePath, sourceFilePath); + let virBasePath = "/resources/"; + if (!this._isSourceNamespaced) { + virBasePath += `${this._namespace}/`; + } + return `${virBasePath}${relSourceFilePath}`; + } + + throw new Error( + `Unable to convert source path ${sourceFilePath} to virtual path for project ${this.getName()}`); + } + /* === Resource Access === */ _getStyledReader(style) { @@ -62,7 +81,7 @@ class ThemeLibrary extends Project { // const excludes = this.getBuilderResourcesExcludes(); // const reader = this._getReader(excludes); - // const writer = this._getWriter(); + // const writer = this._createWriter(); // return resourceFactory.createWorkspace({ // reader, // writer @@ -93,7 +112,7 @@ class ThemeLibrary extends Project { return reader; } - _getWriter() { + _createWriter() { if (!this._writer) { this._writer = resourceFactory.createAdapter({ virBasePath: "/", diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index 64b36ab85e9..548703e5e32 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -16,6 +16,8 @@ function getMockProject(type, id = "b") { getVersion: noop, getReader: () => "reader", getWorkspace: () => "workspace", + sealWorkspace: noop, + createNewWorkspaceVersion: noop, }; } diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index 84b46bb9bbe..a93b1eebc02 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -58,7 +58,9 @@ function getMockProject(type) { getCustomTasks: () => [], hasBuildManifest: () => false, getWorkspace: () => "workspace", - isFrameworkProject: () => false + isFrameworkProject: () => false, + sealWorkspace: noop, + createNewWorkspaceVersion: noop, }; } @@ -118,6 +120,10 @@ test.beforeEach(async (t) => { isLevelEnabled: sinon.stub().returns(true), }; + t.context.cache = { + setTasks: sinon.stub(), + }; + t.context.resourceFactory = { createReaderCollection: sinon.stub() .returns("reader collection") @@ -134,7 +140,7 @@ test.afterEach.always((t) => { }); test("Missing parameters", (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; t.throws(() => { new TaskRunner({ graph, @@ -152,6 +158,7 @@ test("Missing parameters", (t) => { taskUtil, taskRepository, log: projectBuildLogger, + cache, buildConfig }); }, { @@ -163,6 +170,7 @@ test("Missing parameters", (t) => { graph, taskRepository, log: projectBuildLogger, + cache, buildConfig }); }, { @@ -174,6 +182,7 @@ test("Missing parameters", (t) => { graph, taskUtil, log: projectBuildLogger, + cache, buildConfig }); }, { @@ -197,6 +206,7 @@ test("Missing parameters", (t) => { taskUtil, taskRepository, log: projectBuildLogger, + cache, }); }, { message: "TaskRunner: One or more mandatory parameters not provided" @@ -228,9 +238,9 @@ test("_initTasks: Project of type 'application'", async (t) => { }); test("_initTasks: Project of type 'library'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("library"), graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project: getMockProject("library"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -254,13 +264,13 @@ test("_initTasks: Project of type 'library'", async (t) => { }); test("_initTasks: Project of type 'library' (framework project)", async (t) => { - const {graph, taskUtil, taskRepository, projectBuildLogger, TaskRunner} = t.context; + const {graph, taskUtil, taskRepository, projectBuildLogger, TaskRunner, cache} = t.context; const project = getMockProject("library"); project.isFrameworkProject = () => true; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -284,9 +294,9 @@ test("_initTasks: Project of type 'library' (framework project)", async (t) => { }); test("_initTasks: Project of type 'theme-library'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("theme-library"), graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project: getMockProject("theme-library"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -300,13 +310,13 @@ test("_initTasks: Project of type 'theme-library'", async (t) => { }); test("_initTasks: Project of type 'theme-library' (framework project)", async (t) => { - const {graph, taskUtil, taskRepository, projectBuildLogger, TaskRunner} = t.context; + const {graph, taskUtil, taskRepository, projectBuildLogger, cache, TaskRunner} = t.context; const project = getMockProject("theme-library"); project.isFrameworkProject = () => true; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -320,9 +330,9 @@ test("_initTasks: Project of type 'theme-library' (framework project)", async (t }); test("_initTasks: Project of type 'module'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("module"), graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project: getMockProject("module"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -330,9 +340,9 @@ test("_initTasks: Project of type 'module'", async (t) => { }); test("_initTasks: Unknown project type", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("pony"), graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project: getMockProject("pony"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(taskRunner._initTasks()); @@ -340,14 +350,14 @@ test("_initTasks: Unknown project type", async (t) => { }); test("_initTasks: Custom tasks", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "minify"}, {name: "myOtherTask", beforeTask: "replaceVersion"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -371,14 +381,14 @@ test("_initTasks: Custom tasks", async (t) => { }); test("_initTasks: Custom tasks with no standard tasks", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); project.getCustomTasks = () => [ {name: "myTask"}, {name: "myOtherTask", beforeTask: "myTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -388,14 +398,14 @@ test("_initTasks: Custom tasks with no standard tasks", async (t) => { }); test("_initTasks: Custom tasks with no standard tasks and second task defining no before-/afterTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); project.getCustomTasks = () => [ {name: "myTask"}, {name: "myOtherTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -407,13 +417,13 @@ test("_initTasks: Custom tasks with no standard tasks and second task defining n }); test("_initTasks: Custom tasks with both, before- and afterTask reference", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", beforeTask: "minify", afterTask: "replaceVersion"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -425,13 +435,13 @@ test("_initTasks: Custom tasks with both, before- and afterTask reference", asyn }); test("_initTasks: Custom tasks with no before-/afterTask reference", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -443,13 +453,13 @@ test("_initTasks: Custom tasks with no before-/afterTask reference", async (t) = }); test("_initTasks: Custom tasks without name", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: ""} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -460,13 +470,13 @@ test("_initTasks: Custom tasks without name", async (t) => { }); test("_initTasks: Custom task with name of standard tasks", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "replaceVersion", afterTask: "minify"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -478,7 +488,7 @@ test("_initTasks: Custom task with name of standard tasks", async (t) => { }); test("_initTasks: Multiple custom tasks with same name", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "minify"}, @@ -486,7 +496,7 @@ test("_initTasks: Multiple custom tasks with same name", async (t) => { {name: "myTask", afterTask: "minify"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -511,13 +521,13 @@ test("_initTasks: Multiple custom tasks with same name", async (t) => { }); test("_initTasks: Custom tasks with unknown beforeTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", beforeTask: "unknownTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -529,13 +539,13 @@ test("_initTasks: Custom tasks with unknown beforeTask", async (t) => { }); test("_initTasks: Custom tasks with unknown afterTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "unknownTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -547,14 +557,14 @@ test("_initTasks: Custom tasks with unknown afterTask", async (t) => { }); test("_initTasks: Custom tasks is unknown", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; graph.getExtension.returns(undefined); const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "minify"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -566,13 +576,13 @@ test("_initTasks: Custom tasks is unknown", async (t) => { }); test("_initTasks: Custom tasks with removed beforeTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", beforeTask: "removedTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -585,10 +595,10 @@ test("_initTasks: Custom tasks with removed beforeTask", async (t) => { }); test("_initTasks: Create dependencies reader for all dependencies", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); t.is(graph.traverseBreadthFirst.callCount, 1, "ProjectGraph#traverseBreadthFirst called once"); @@ -631,7 +641,7 @@ test("_initTasks: Create dependencies reader for all dependencies", async (t) => }); test("Custom task is called correctly", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(false); const mockSpecVersion = { @@ -652,7 +662,7 @@ test("Custom task is called correctly", async (t) => { ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -692,7 +702,7 @@ test("Custom task is called correctly", async (t) => { }); test("Custom task with legacy spec version", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(false); const mockSpecVersion = { @@ -712,7 +722,7 @@ test("Custom task with legacy spec version", async (t) => { ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -752,7 +762,7 @@ test("Custom task with legacy spec version", async (t) => { }); test("Custom task with legacy spec version and requiredDependenciesCallback", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(false); const mockSpecVersion = { @@ -773,7 +783,7 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -824,7 +834,7 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as }); test("Custom task with specVersion 3.0", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -848,7 +858,7 @@ test("Custom task with specVersion 3.0", async (t) => { ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -921,7 +931,7 @@ test("Custom task with specVersion 3.0", async (t) => { }); test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", async (t) => { - const {sinon, graph, taskUtil, taskRepository, projectBuildLogger, TaskRunner} = t.context; + const {sinon, graph, taskUtil, taskRepository, projectBuildLogger, cache, TaskRunner} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -944,7 +954,7 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -982,7 +992,7 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy }); test("Multiple custom tasks with same name are called correctly", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStubA = sinon.stub(); const taskStubB = sinon.stub(); const taskStubC = sinon.stub(); @@ -1042,7 +1052,7 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { {name: "myTask", afterTask: "myTask", configuration: "bird"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -1184,7 +1194,7 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { }); test("Custom task: requiredDependenciesCallback returns unknown dependency", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -1209,7 +1219,7 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await t.throwsAsync(taskRunner._initTasks(), { message: @@ -1221,7 +1231,7 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy test("Custom task: requiredDependenciesCallback returns Array instead of Set", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -1246,7 +1256,7 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await t.throwsAsync(taskRunner._initTasks(), { message: @@ -1256,7 +1266,7 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a }); test("Custom task attached to a disabled task", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, sinon, customTask} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache, sinon, customTask} = t.context; const project = getMockProject("application"); const customTaskFnStub = sinon.stub(); @@ -1269,7 +1279,7 @@ test("Custom task attached to a disabled task", async (t) => { customTask.getTask = () => customTaskFnStub; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner.runTasks(); @@ -1296,7 +1306,7 @@ test("Custom task attached to a disabled task", async (t) => { }); test.serial("_addTask", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); taskRepository.getTask.withArgs("standardTask").resolves({ @@ -1305,7 +1315,7 @@ test.serial("_addTask", async (t) => { const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -1338,12 +1348,12 @@ test.serial("_addTask", async (t) => { }); test.serial("_addTask with options", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskStub = sinon.stub(); const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -1384,10 +1394,10 @@ test.serial("_addTask with options", async (t) => { }); test("_addTask: Duplicate task", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -1405,10 +1415,10 @@ test("_addTask: Duplicate task", async (t) => { }); test("_addTask: Task already added to execution order", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); @@ -1424,13 +1434,13 @@ test("_addTask: Task already added to execution order", async (t) => { }); test("getRequiredDependencies: Custom Task", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); project.getCustomTasks = () => [ {name: "myTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Project with custom task >= specVersion 3.0 and no requiredDependenciesCallback " + @@ -1438,11 +1448,11 @@ test("getRequiredDependencies: Custom Task", async (t) => { }); test("getRequiredDependencies: Default application", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("application"); project.getBundles = () => []; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Default application project does not require dependencies"); @@ -1460,44 +1470,44 @@ test("getRequiredDependencies: Default component", async (t) => { }); test("getRequiredDependencies: Default library", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("library"); project.getBundles = () => []; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set(["dep.a", "dep.b"]), "Default library project requires dependencies"); }); test("getRequiredDependencies: Default theme-library", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("theme-library"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set(["dep.a", "dep.b"]), "Default theme-library project requires dependencies"); }); test("getRequiredDependencies: Default module", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Default module project does not require dependencies"); }); test("_createDependenciesReader", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); graph.traverseBreadthFirst.reset(); // Ignore the call in initTask @@ -1554,11 +1564,11 @@ test("_createDependenciesReader", async (t) => { }); test("_createDependenciesReader: All dependencies required", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); graph.traverseBreadthFirst.reset(); // Ignore the call in initTask @@ -1571,11 +1581,11 @@ test("_createDependenciesReader: All dependencies required", async (t) => { }); test("_createDependenciesReader: No dependencies required", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); graph.traverseBreadthFirst.reset(); // Ignore the call in initTask diff --git a/packages/project/test/lib/build/helpers/ProjectBuildContext.js b/packages/project/test/lib/build/helpers/ProjectBuildContext.js index 03f9a568325..74b06d49927 100644 --- a/packages/project/test/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/test/lib/build/helpers/ProjectBuildContext.js @@ -1,6 +1,7 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; +import ProjectBuildCache from "../../../../lib/build/helpers/ProjectBuildCache.js"; import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; test.beforeEach((t) => { @@ -315,7 +316,7 @@ test("getTaskUtil", (t) => { }); test.serial("getTaskRunner", async (t) => { - t.plan(3); + t.plan(4); const project = { getName: () => "project", getType: () => "type", @@ -325,10 +326,13 @@ test.serial("getTaskRunner", async (t) => { constructor(params) { t.true(params.log instanceof ProjectBuildLogger, "TaskRunner receives an instance of ProjectBuildLogger"); params.log = "log"; // replace log instance with string for deep comparison + t.true(params.cache instanceof ProjectBuildCache, "TaskRunner receives an instance of ProjectBuildCache"); + params.cache = "cache"; // replace cache instance with string for deep comparison t.deepEqual(params, { graph: "graph", project: project, log: "log", + cache: "cache", taskUtil: "taskUtil", taskRepository: "taskRepository", buildConfig: "buildConfig" From f44467e5caeb6d7aa01232c4692c7228725a0cb1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 18 Nov 2025 16:03:17 +0100 Subject: [PATCH 007/223] refactor(cli): Use cache in ui5 build Cherry-picked from: https://github.com/SAP/ui5-cli/commit/d29ead8326c43690c7c792bb15ff41402a3d9f25 JIRA: CPOUI5FOUNDATION-1174 --- packages/cli/lib/cli/commands/build.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/lib/cli/commands/build.js b/packages/cli/lib/cli/commands/build.js index df93ac5a12e..31e7b56062c 100644 --- a/packages/cli/lib/cli/commands/build.js +++ b/packages/cli/lib/cli/commands/build.js @@ -1,4 +1,5 @@ import baseMiddleware from "../middlewares/base.js"; +import path from "node:path"; const build = { command: "build", @@ -173,6 +174,7 @@ async function handleBuild(argv) { const buildSettings = graph.getRoot().getBuilderSettings() || {}; await graph.build({ graph, + cacheDir: path.join(graph.getRoot().getRootPath(), ".ui5-cache"), destPath: argv.dest, cleanDest: argv["clean-dest"], createBuildManifest: argv["create-build-manifest"], From b4f23e4d235ce715c437d0908a867d6cd3f9eb41 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 24 Nov 2025 17:06:48 +0100 Subject: [PATCH 008/223] refactor(project): Use cacache --- package-lock.json | 4904 ++++----------------------------- packages/project/package.json | 2 + 2 files changed, 559 insertions(+), 4347 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c94af61718..d51c18651ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,8 +97,6 @@ }, "internal/shrinkwrap-extractor/node_modules/@npmcli/arborist": { "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.4.2.tgz", - "integrity": "sha512-omJgPyzt11cEGrxzgrECoOyxAunmPMgBFTcAB/FbaB+9iOYhGmRdsQqySV8o0LWQ/l2kTeASUIMR4xJufVwmtw==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -145,8 +143,6 @@ }, "internal/shrinkwrap-extractor/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -154,8 +150,6 @@ }, "internal/shrinkwrap-extractor/node_modules/nopt": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "dependencies": { "abbrev": "^4.0.0" @@ -169,14 +163,10 @@ }, "node_modules/@adobe/css-tools": { "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "license": "MIT" }, "node_modules/@algolia/abtesting": { "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.16.1.tgz", - "integrity": "sha512-Xxk4l00pYI+jE0PNw8y0MvsQWh5278WRtZQav8/BMMi3HKi2xmeuqe11WJ3y8/6nuBHdv39w76OpJb09TMfAVQ==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -190,8 +180,6 @@ }, "node_modules/@algolia/autocomplete-core": { "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", "license": "MIT", "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", @@ -200,8 +188,6 @@ }, "node_modules/@algolia/autocomplete-plugin-algolia-insights": { "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.7" @@ -212,8 +198,6 @@ }, "node_modules/@algolia/autocomplete-preset-algolia": { "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.7" @@ -225,8 +209,6 @@ }, "node_modules/@algolia/autocomplete-shared": { "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -235,8 +217,6 @@ }, "node_modules/@algolia/client-abtesting": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.50.1.tgz", - "integrity": "sha512-4peZlPXMwTOey9q1rQKMdCnwZb/E95/1e+7KujXpLLSh0FawJzg//U2NM+r4AiJy4+naT2MTBhj0K30yshnVTA==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -250,8 +230,6 @@ }, "node_modules/@algolia/client-analytics": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.50.1.tgz", - "integrity": "sha512-i+aWHHG8NZvGFHtPeMZkxL2Loc6Fm7iaRo15lYSMx8gFL+at9vgdWxhka7mD1fqxkrxXsQstUBCIsSY8FvkEOw==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -265,8 +243,6 @@ }, "node_modules/@algolia/client-common": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.50.1.tgz", - "integrity": "sha512-Hw52Fwapyk/7hMSV/fI4+s3H9MGZEUcRh4VphyXLAk2oLYdndVUkc6KBi0zwHSzwPAr+ZBwFPe2x6naUt9mZGw==", "license": "MIT", "engines": { "node": ">= 14.0.0" @@ -274,8 +250,6 @@ }, "node_modules/@algolia/client-insights": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.50.1.tgz", - "integrity": "sha512-Bn/wtwhJ7p1OD/6pY+Zzn+zlu2N/SJnH46md/PAbvqIzmjVuwjNwD4y0vV5Ov8naeukXdd7UU9v550+v8+mtlg==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -289,8 +263,6 @@ }, "node_modules/@algolia/client-personalization": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.50.1.tgz", - "integrity": "sha512-0V4Tu0RWR8YxkgI9EPVOZHGE4K5pEIhkLNN0CTkP/rnPsqaaSQpNMYW3/mGWdiKOWbX0iVmwLB9QESk3H0jS5g==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -304,8 +276,6 @@ }, "node_modules/@algolia/client-query-suggestions": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.50.1.tgz", - "integrity": "sha512-jofcWNYMXJDDr87Z2eivlWY6o71Zn7F7aOvQCXSDAo9QTlyf7BhXEsZymLUvF0O1yU9Q9wvrjAWn8uVHYnAvgw==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -319,8 +289,6 @@ }, "node_modules/@algolia/client-search": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.50.1.tgz", - "integrity": "sha512-OteRb8WubcmEvU0YlMJwCXs3Q6xrdkb0v50/qZBJP1TF0CvujFZQM++9BjEkTER/Jr9wbPHvjSFKnbMta0b4dQ==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -334,8 +302,6 @@ }, "node_modules/@algolia/ingestion": { "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.50.1.tgz", - "integrity": "sha512-0GmfSgDQK6oiIVXnJvGxtNFOfosBspRTR7csCOYCTL1P8QtxX2vDCIKwTM7xdSAEbJaZ43QlWg25q0Qdsndz8Q==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -349,8 +315,6 @@ }, "node_modules/@algolia/monitoring": { "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.50.1.tgz", - "integrity": "sha512-ySuigKEe4YjYV3si8NVk9BHQpFj/1B+ON7DhhvTvbrZJseHQQloxzq0yHwKmznSdlO6C956fx4pcfOKkZClsyg==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -364,8 +328,6 @@ }, "node_modules/@algolia/recommend": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.50.1.tgz", - "integrity": "sha512-Cp8T/B0gVmjFlzzp6eP47hwKh5FGyeqQp1N48/ANDdvdiQkPqLyFHQVDwLBH0LddfIPQE+yqmZIgmKc82haF4A==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1", @@ -379,8 +341,6 @@ }, "node_modules/@algolia/requester-browser-xhr": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.1.tgz", - "integrity": "sha512-XKdGGLikfrlK66ZSXh/vWcXZZ8Vg3byDFbJD8pwEvN1FoBRGxhxya476IY2ohoTymLa4qB5LBRlIa+2TLHx3Uw==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1" @@ -391,8 +351,6 @@ }, "node_modules/@algolia/requester-fetch": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.50.1.tgz", - "integrity": "sha512-mBAU6WyVsDwhHyGM+nodt1/oebHxgvuLlOAoMGbj/1i6LygDHZWDgL1t5JEs37x9Aywv7ZGhqbM1GsfZ54sU6g==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1" @@ -403,8 +361,6 @@ }, "node_modules/@algolia/requester-node-http": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.50.1.tgz", - "integrity": "sha512-qmo1LXrNKLHvJE6mdQbLnsZAoZvj7VyF2ft4xmbSGWI2WWm87fx/CjUX4kEExt4y0a6T6nEts6ofpUfH5TEE1A==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.50.1" @@ -415,8 +371,6 @@ }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz", - "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==", "dev": true, "license": "MIT", "dependencies": { @@ -434,8 +388,6 @@ }, "node_modules/@babel/code-frame": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -448,8 +400,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -458,8 +408,6 @@ }, "node_modules/@babel/core": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { @@ -489,8 +437,6 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -499,8 +445,6 @@ }, "node_modules/@babel/generator": { "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { @@ -516,8 +460,6 @@ }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { @@ -529,8 +471,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { @@ -546,8 +486,6 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -556,8 +494,6 @@ }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "dev": true, "license": "MIT", "dependencies": { @@ -578,8 +514,6 @@ }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -588,8 +522,6 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { @@ -598,8 +530,6 @@ }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, "license": "MIT", "dependencies": { @@ -612,8 +542,6 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { @@ -626,8 +554,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { @@ -644,8 +570,6 @@ }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, "license": "MIT", "dependencies": { @@ -657,8 +581,6 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -667,8 +589,6 @@ }, "node_modules/@babel/helper-replace-supers": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "dev": true, "license": "MIT", "dependencies": { @@ -685,8 +605,6 @@ }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "license": "MIT", "dependencies": { @@ -699,8 +617,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -708,8 +624,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -717,8 +631,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -727,8 +639,6 @@ }, "node_modules/@babel/helpers": { "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { @@ -741,8 +651,6 @@ }, "node_modules/@babel/parser": { "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -756,8 +664,6 @@ }, "node_modules/@babel/plugin-syntax-decorators": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", - "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -772,8 +678,6 @@ }, "node_modules/@babel/plugin-syntax-jsx": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { @@ -788,8 +692,6 @@ }, "node_modules/@babel/plugin-syntax-typescript": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { @@ -804,8 +706,6 @@ }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "dev": true, "license": "MIT", "dependencies": { @@ -821,8 +721,6 @@ }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", "dev": true, "license": "MIT", "dependencies": { @@ -841,8 +739,6 @@ }, "node_modules/@babel/preset-typescript": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "dev": true, "license": "MIT", "dependencies": { @@ -861,8 +757,6 @@ }, "node_modules/@babel/template": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { @@ -876,8 +770,6 @@ }, "node_modules/@babel/traverse": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { @@ -895,8 +787,6 @@ }, "node_modules/@babel/types": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -908,21 +798,15 @@ }, "node_modules/@blueoak/list": { "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@blueoak/list/-/list-15.0.0.tgz", - "integrity": "sha512-xW5Xb9Fr3WtYAOwavxxWL0CaJK/ReT+HKb5/R6dR1p9RVJ55MTdaxPdeTKY2ukhFchv2YHPMM8YuZyfyLqxedg==", "dev": true, "license": "CC0-1.0" }, "node_modules/@colordx/core": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.0.3.tgz", - "integrity": "sha512-xBQ0MYRTNNxW3mS2sJtlQTT7C3Sasqgh1/PsHva7fyDb5uqYY+gv9V0utDdX8X80mqzbGz3u/IDJdn2d/uW09g==", "license": "MIT" }, "node_modules/@commitlint/cli": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", - "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -943,8 +827,6 @@ }, "node_modules/@commitlint/config-conventional": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", - "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", "dev": true, "license": "MIT", "dependencies": { @@ -957,8 +839,6 @@ }, "node_modules/@commitlint/config-validator": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", - "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", "dev": true, "license": "MIT", "dependencies": { @@ -971,8 +851,6 @@ }, "node_modules/@commitlint/ensure": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", - "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", "dev": true, "license": "MIT", "dependencies": { @@ -989,8 +867,6 @@ }, "node_modules/@commitlint/execute-rule": { "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", - "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", "dev": true, "license": "MIT", "engines": { @@ -999,8 +875,6 @@ }, "node_modules/@commitlint/format": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", - "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1013,8 +887,6 @@ }, "node_modules/@commitlint/is-ignored": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", - "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", "dev": true, "license": "MIT", "dependencies": { @@ -1027,8 +899,6 @@ }, "node_modules/@commitlint/lint": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", - "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", "dev": true, "license": "MIT", "dependencies": { @@ -1043,8 +913,6 @@ }, "node_modules/@commitlint/load": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", - "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", "dev": true, "license": "MIT", "dependencies": { @@ -1064,8 +932,6 @@ }, "node_modules/@commitlint/message": { "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", - "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", "dev": true, "license": "MIT", "engines": { @@ -1074,8 +940,6 @@ }, "node_modules/@commitlint/parse": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", - "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", "dev": true, "license": "MIT", "dependencies": { @@ -1089,8 +953,6 @@ }, "node_modules/@commitlint/read": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", - "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", "dev": true, "license": "MIT", "dependencies": { @@ -1106,8 +968,6 @@ }, "node_modules/@commitlint/resolve-extends": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", - "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", "dev": true, "license": "MIT", "dependencies": { @@ -1124,8 +984,6 @@ }, "node_modules/@commitlint/rules": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", - "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1140,8 +998,6 @@ }, "node_modules/@commitlint/to-lines": { "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", - "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", "dev": true, "license": "MIT", "engines": { @@ -1150,8 +1006,6 @@ }, "node_modules/@commitlint/top-level": { "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", - "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1163,8 +1017,6 @@ }, "node_modules/@commitlint/types": { "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", - "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", "dev": true, "license": "MIT", "dependencies": { @@ -1177,8 +1029,6 @@ }, "node_modules/@conventional-changelog/git-client": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz", - "integrity": "sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1204,14 +1054,10 @@ }, "node_modules/@docsearch/css": { "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", "license": "MIT" }, "node_modules/@docsearch/js": { "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", "license": "MIT", "dependencies": { "@docsearch/react": "3.8.2", @@ -1220,8 +1066,6 @@ }, "node_modules/@docsearch/react": { "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.17.7", @@ -1254,8 +1098,10 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -1265,8 +1111,10 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1275,23 +1123,21 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@epic-web/invariant": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", "dev": true, "license": "MIT" }, "node_modules/@es-joy/jsdoccomment": { "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", - "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", "dev": true, "license": "MIT", "dependencies": { @@ -1307,784 +1153,362 @@ }, "node_modules/@es-joy/resolve.exports": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", - "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", "dev": true, "license": "MIT", "engines": { "node": ">=10" } }, - "node_modules/@esbuild/aix-ppc64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ - "ppc64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { "node": ">=12" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=12" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=12" + "node": "*" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=12" + "node": "*" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], + "node_modules/@gar/promise-retry": { + "version": "1.0.3", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=12" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": ">=12" + "node": ">=18.18.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=12.22" }, "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=18.18" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.76", + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@iconify/types": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "license": "ISC", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", "license": "MIT" }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "minipass": "^7.0.4" }, "engines": { - "node": "*" + "node": ">=18.0.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "license": "ISC" + }, + "node_modules/@istanbuljs/esm-loader-hook": { + "version": "0.3.0", "dev": true, - "license": "Apache-2.0", + "license": "ISC", "dependencies": { - "@eslint/core": "^0.17.0" + "@babel/core": "^7.8.7", + "@babel/plugin-syntax-decorators": "^7.25.9", + "@babel/preset-typescript": "^7.26.0", + "@istanbuljs/load-nyc-config": "^1.1.0", + "@istanbuljs/schema": "^0.1.3", + "babel-plugin-istanbul": "^6.0.0", + "test-exclude": "^6.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.12.0" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@gar/promise-retry": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", - "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.76", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.76.tgz", - "integrity": "sha512-lLRlA8yaf+1L5VCPRvR9lynoSklsddKHEylchmZJKdj/q2xVQ1ZAEJ8SCQlv9cbgtMefnlyM98U+8Si2aoFZPA==", - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", - "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", - "license": "ISC" - }, - "node_modules/@istanbuljs/esm-loader-hook": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/esm-loader-hook/-/esm-loader-hook-0.3.0.tgz", - "integrity": "sha512-lEnYroBUYfNQuJDYrPvre8TSwPZnyIQv9qUT3gACvhr3igZr+BbrdyIcz4+2RnEXZzi12GqkUW600+QQPpIbVg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@babel/core": "^7.8.7", - "@babel/plugin-syntax-decorators": "^7.25.9", - "@babel/preset-typescript": "^7.26.0", - "@istanbuljs/load-nyc-config": "^1.1.0", - "@istanbuljs/schema": "^0.1.3", - "babel-plugin-istanbul": "^6.0.0", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=16.12.0" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", + "license": "ISC", "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2098,8 +1522,6 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", "dependencies": { @@ -2108,8 +1530,6 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -2122,8 +1542,6 @@ }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", "engines": { @@ -2132,8 +1550,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2142,8 +1558,6 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2152,8 +1566,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2161,8 +1573,6 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2171,14 +1581,10 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2187,8 +1593,6 @@ }, "node_modules/@jsdoc/salty": { "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.11.tgz", - "integrity": "sha512-luR/TZqgru6gNjBQnRIbzNPOmDG62VIFQO7QyEjc1/zk3VP3yoGfuecwP2uOlAmKz+t6aq9bwsBV3FgiyHTT7Q==", "license": "Apache-2.0", "dependencies": { "lodash": "^4.17.23" @@ -2199,8 +1603,6 @@ }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", - "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2220,9 +1622,10 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2239,8 +1642,6 @@ }, "node_modules/@noble/hashes": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "license": "MIT", "engines": { @@ -2252,8 +1653,6 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2265,8 +1664,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "license": "MIT", "engines": { "node": ">= 8" @@ -2274,8 +1671,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2287,8 +1682,6 @@ }, "node_modules/@npmcli/agent": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", "license": "ISC", "dependencies": { "agent-base": "^7.1.0", @@ -2303,8 +1696,6 @@ }, "node_modules/@npmcli/agent/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2312,8 +1703,6 @@ }, "node_modules/@npmcli/arborist": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-7.5.4.tgz", - "integrity": "sha512-nWtIc6QwwoUORCRNzKx4ypHqCk3drI+5aeYdMTQQiRCcn4lOOgfQh7WyZobGYTxXPSq1VwV53lkpN/BRlRk08g==", "dev": true, "license": "ISC", "dependencies": { @@ -2362,8 +1751,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/agent": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", - "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", "dev": true, "license": "ISC", "dependencies": { @@ -2379,8 +1766,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/fs": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", - "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", "dev": true, "license": "ISC", "dependencies": { @@ -2392,8 +1777,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/git": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", - "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2413,8 +1796,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/installed-package-contents": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz", - "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==", "dev": true, "license": "ISC", "dependencies": { @@ -2430,8 +1811,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/map-workspaces": { "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-3.0.6.tgz", - "integrity": "sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA==", "dev": true, "license": "ISC", "dependencies": { @@ -2446,8 +1825,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/metavuln-calculator": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-7.1.1.tgz", - "integrity": "sha512-Nkxf96V0lAx3HCpVda7Vw4P23RILgdi/5K1fmj2tZkWIYLpXAN8k2UVVOsW16TsS5F8Ws2I7Cm+PU1/rsVF47g==", "dev": true, "license": "ISC", "dependencies": { @@ -2463,8 +1840,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/name-from-folder": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-2.0.0.tgz", - "integrity": "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==", "dev": true, "license": "ISC", "engines": { @@ -2473,8 +1848,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/node-gyp": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", - "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", "dev": true, "license": "ISC", "engines": { @@ -2483,8 +1856,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/package-json": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", - "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2502,8 +1873,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/promise-spawn": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", - "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2515,8 +1884,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/query": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-3.1.0.tgz", - "integrity": "sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2528,8 +1895,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/redact": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-2.0.1.tgz", - "integrity": "sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==", "dev": true, "license": "ISC", "engines": { @@ -2538,8 +1903,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@npmcli/run-script": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-8.1.0.tgz", - "integrity": "sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==", "dev": true, "license": "ISC", "dependencies": { @@ -2556,8 +1919,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/bundle": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", - "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2569,8 +1930,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/core": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", - "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2579,8 +1938,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/protobuf-specs": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.3.tgz", - "integrity": "sha512-fk2zjD9117RL9BjqEwF7fwv7Q/P9yGsMV4MUJZ/DocaQJ6+3pKr+syBq1owU5Q5qGw5CUbXzm+4yJ2JVRDQeSA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2589,8 +1946,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", - "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2607,8 +1962,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/@npmcli/agent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, "license": "ISC", "dependencies": { @@ -2624,8 +1977,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/@npmcli/fs": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, "license": "ISC", "dependencies": { @@ -2637,8 +1988,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/cacache": { "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2661,8 +2010,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/make-fetch-happen": { "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2684,8 +2031,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/minipass-fetch": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2702,8 +2047,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/minizlib": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -2715,8 +2058,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "license": "MIT", "engines": { @@ -2725,8 +2066,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/p-map": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", "engines": { @@ -2738,8 +2077,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/proc-log": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, "license": "ISC", "engines": { @@ -2748,8 +2085,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/ssri": { "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2761,8 +2096,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/sign/node_modules/unique-filename": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2774,8 +2107,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/tuf": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.1.tgz", - "integrity": "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2788,8 +2119,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@sigstore/verify": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.1.tgz", - "integrity": "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2803,8 +2132,6 @@ }, "node_modules/@npmcli/arborist/node_modules/@tufjs/models": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", - "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", "dev": true, "license": "MIT", "dependencies": { @@ -2817,8 +2144,6 @@ }, "node_modules/@npmcli/arborist/node_modules/abbrev": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "dev": true, "license": "ISC", "engines": { @@ -2827,15 +2152,11 @@ }, "node_modules/@npmcli/arborist/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/@npmcli/arborist/node_modules/bin-links": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.4.tgz", - "integrity": "sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==", "dev": true, "license": "ISC", "dependencies": { @@ -2850,8 +2171,6 @@ }, "node_modules/@npmcli/arborist/node_modules/brace-expansion": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -2860,8 +2179,6 @@ }, "node_modules/@npmcli/arborist/node_modules/cacache": { "version": "18.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", - "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2884,8 +2201,6 @@ }, "node_modules/@npmcli/arborist/node_modules/cmd-shim": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.3.tgz", - "integrity": "sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==", "dev": true, "license": "ISC", "engines": { @@ -2894,15 +2209,11 @@ }, "node_modules/@npmcli/arborist/node_modules/common-ancestor-path": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", - "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", "dev": true, "license": "ISC" }, "node_modules/@npmcli/arborist/node_modules/hosted-git-info": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", "dev": true, "license": "ISC", "dependencies": { @@ -2914,8 +2225,6 @@ }, "node_modules/@npmcli/arborist/node_modules/ignore-walk": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", - "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2927,8 +2236,6 @@ }, "node_modules/@npmcli/arborist/node_modules/ini": { "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", - "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", "dev": true, "license": "ISC", "engines": { @@ -2937,8 +2244,6 @@ }, "node_modules/@npmcli/arborist/node_modules/isexe": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2947,8 +2252,6 @@ }, "node_modules/@npmcli/arborist/node_modules/json-parse-even-better-errors": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", - "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", "dev": true, "license": "MIT", "engines": { @@ -2957,15 +2260,11 @@ }, "node_modules/@npmcli/arborist/node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/@npmcli/arborist/node_modules/make-fetch-happen": { "version": "13.0.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", - "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", "dev": true, "license": "ISC", "dependencies": { @@ -2988,8 +2287,6 @@ }, "node_modules/@npmcli/arborist/node_modules/minimatch": { "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { @@ -3004,8 +2301,6 @@ }, "node_modules/@npmcli/arborist/node_modules/minipass-fetch": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", - "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", "dev": true, "license": "MIT", "dependencies": { @@ -3022,8 +2317,6 @@ }, "node_modules/@npmcli/arborist/node_modules/minipass-sized": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "dev": true, "license": "ISC", "dependencies": { @@ -3035,8 +2328,6 @@ }, "node_modules/@npmcli/arborist/node_modules/minipass-sized/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", "dependencies": { @@ -3048,8 +2339,6 @@ }, "node_modules/@npmcli/arborist/node_modules/minizlib": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "dev": true, "license": "MIT", "dependencies": { @@ -3062,8 +2351,6 @@ }, "node_modules/@npmcli/arborist/node_modules/minizlib/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", "dependencies": { @@ -3075,8 +2362,6 @@ }, "node_modules/@npmcli/arborist/node_modules/negotiator": { "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true, "license": "MIT", "engines": { @@ -3085,8 +2370,6 @@ }, "node_modules/@npmcli/arborist/node_modules/node-gyp": { "version": "10.3.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz", - "integrity": "sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3110,8 +2393,6 @@ }, "node_modules/@npmcli/arborist/node_modules/nopt": { "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "dev": true, "license": "ISC", "dependencies": { @@ -3126,8 +2407,6 @@ }, "node_modules/@npmcli/arborist/node_modules/normalize-package-data": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3141,8 +2420,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-bundled": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", - "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3154,8 +2431,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-install-checks": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3167,8 +2442,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-normalize-package-bin": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", "dev": true, "license": "ISC", "engines": { @@ -3177,8 +2450,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-package-arg": { "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", - "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", "dev": true, "license": "ISC", "dependencies": { @@ -3193,8 +2464,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-packlist": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", - "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3206,8 +2475,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-pick-manifest": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", - "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", "dev": true, "license": "ISC", "dependencies": { @@ -3222,8 +2489,6 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-registry-fetch": { "version": "17.1.0", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz", - "integrity": "sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==", "dev": true, "license": "ISC", "dependencies": { @@ -3242,8 +2507,6 @@ }, "node_modules/@npmcli/arborist/node_modules/p-map": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3258,8 +2521,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote": { "version": "20.0.1", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.1.tgz", - "integrity": "sha512-jTMLD/QK7JMUKg3g7K3M/DEqIbGm7sxclj12eQYIkL3viutSiefTs26IrqIqgGlFsviF/9dlDUZxnpGvkRXtjw==", "dev": true, "license": "ISC", "dependencies": { @@ -3290,8 +2551,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/agent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, "license": "ISC", "dependencies": { @@ -3307,8 +2566,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/fs": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, "license": "ISC", "dependencies": { @@ -3320,8 +2577,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/git": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", - "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3340,8 +2595,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/installed-package-contents": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", - "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", "dev": true, "license": "ISC", "dependencies": { @@ -3357,8 +2610,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/node-gyp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", - "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", "dev": true, "license": "ISC", "engines": { @@ -3367,8 +2618,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/package-json": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.2.0.tgz", - "integrity": "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==", "dev": true, "license": "ISC", "dependencies": { @@ -3386,8 +2635,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/promise-spawn": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.3.tgz", - "integrity": "sha512-Yb00SWaL4F8w+K8YGhQ55+xE4RUNdMHV43WZGsiTM92gS+lC0mGsn7I4hLug7pbao035S6bj3Y3w0cUNGLfmkg==", "dev": true, "license": "ISC", "dependencies": { @@ -3399,8 +2646,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/redact": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", - "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", "dev": true, "license": "ISC", "engines": { @@ -3409,8 +2654,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/@npmcli/run-script": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", - "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", "dev": true, "license": "ISC", "dependencies": { @@ -3427,8 +2670,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/abbrev": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", "dev": true, "license": "ISC", "engines": { @@ -3437,8 +2678,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/cacache": { "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3461,8 +2700,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/hosted-git-info": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", "dev": true, "license": "ISC", "dependencies": { @@ -3474,8 +2711,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/ini": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", "dev": true, "license": "ISC", "engines": { @@ -3484,8 +2719,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/json-parse-even-better-errors": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", "dev": true, "license": "MIT", "engines": { @@ -3494,8 +2727,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/make-fetch-happen": { "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3517,8 +2748,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/minipass-fetch": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3535,8 +2764,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/minizlib": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3548,8 +2775,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "license": "MIT", "engines": { @@ -3558,8 +2783,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/node-gyp": { "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3583,8 +2806,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/nopt": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "dev": true, "license": "ISC", "dependencies": { @@ -3599,8 +2820,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/npm-bundled": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", - "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", "dev": true, "license": "ISC", "dependencies": { @@ -3612,8 +2831,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/npm-install-checks": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.2.tgz", - "integrity": "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3625,8 +2842,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/npm-normalize-package-bin": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", "dev": true, "license": "ISC", "engines": { @@ -3635,8 +2850,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/npm-package-arg": { "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", "dev": true, "license": "ISC", "dependencies": { @@ -3651,8 +2864,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/npm-pick-manifest": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", - "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3667,8 +2878,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/npm-registry-fetch": { "version": "18.0.2", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", - "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3687,8 +2896,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/p-map": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", "engines": { @@ -3700,8 +2907,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/proc-log": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, "license": "ISC", "engines": { @@ -3710,8 +2915,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/ssri": { "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3723,8 +2926,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/unique-filename": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3736,8 +2937,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/validate-npm-package-name": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", - "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", "dev": true, "license": "ISC", "engines": { @@ -3746,8 +2945,6 @@ }, "node_modules/@npmcli/arborist/node_modules/pacote/node_modules/which": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3762,8 +2959,6 @@ }, "node_modules/@npmcli/arborist/node_modules/parse-conflict-json": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz", - "integrity": "sha512-01TvEktc68vwbJOtWZluyWeVGWjP+bZwXtPDMQVbBKzbJ/vZBif0L69KH1+cHv1SZ6e0FKLvjyHe8mqsIqYOmw==", "dev": true, "license": "ISC", "dependencies": { @@ -3777,8 +2972,6 @@ }, "node_modules/@npmcli/arborist/node_modules/postcss-selector-parser": { "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", "dependencies": { @@ -3791,8 +2984,6 @@ }, "node_modules/@npmcli/arborist/node_modules/proc-log": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", - "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", "dev": true, "license": "ISC", "engines": { @@ -3801,8 +2992,6 @@ }, "node_modules/@npmcli/arborist/node_modules/proggy": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/proggy/-/proggy-2.0.0.tgz", - "integrity": "sha512-69agxLtnI8xBs9gUGqEnK26UfiexpHy+KUpBQWabiytQjnn5wFY8rklAi7GRfABIuPNnQ/ik48+LGLkYYJcy4A==", "dev": true, "license": "ISC", "engines": { @@ -3811,8 +3000,6 @@ }, "node_modules/@npmcli/arborist/node_modules/read-cmd-shim": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz", - "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==", "dev": true, "license": "ISC", "engines": { @@ -3821,8 +3008,6 @@ }, "node_modules/@npmcli/arborist/node_modules/sigstore": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", - "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3839,8 +3024,6 @@ }, "node_modules/@npmcli/arborist/node_modules/ssri": { "version": "10.0.6", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", - "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3852,8 +3035,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", - "integrity": "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg==", "dev": true, "license": "MIT", "dependencies": { @@ -3867,8 +3048,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/@npmcli/agent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, "license": "ISC", "dependencies": { @@ -3884,8 +3063,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/@npmcli/fs": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, "license": "ISC", "dependencies": { @@ -3897,8 +3074,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/cacache": { "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3921,8 +3096,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/make-fetch-happen": { "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3944,8 +3117,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/minipass-fetch": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3962,8 +3133,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/minizlib": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3975,8 +3144,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "license": "MIT", "engines": { @@ -3985,8 +3152,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/p-map": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", "engines": { @@ -3998,8 +3163,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/proc-log": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, "license": "ISC", "engines": { @@ -4008,8 +3171,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/ssri": { "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, "license": "ISC", "dependencies": { @@ -4021,8 +3182,6 @@ }, "node_modules/@npmcli/arborist/node_modules/tuf-js/node_modules/unique-filename": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, "license": "ISC", "dependencies": { @@ -4034,8 +3193,6 @@ }, "node_modules/@npmcli/arborist/node_modules/unique-slug": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", "dev": true, "license": "ISC", "dependencies": { @@ -4047,8 +3204,6 @@ }, "node_modules/@npmcli/arborist/node_modules/validate-npm-package-name": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", - "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", "dev": true, "license": "ISC", "engines": { @@ -4057,15 +3212,11 @@ }, "node_modules/@npmcli/arborist/node_modules/walk-up-path": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", - "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==", "dev": true, "license": "ISC" }, "node_modules/@npmcli/arborist/node_modules/which": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "dev": true, "license": "ISC", "dependencies": { @@ -4080,8 +3231,6 @@ }, "node_modules/@npmcli/arborist/node_modules/write-file-atomic": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -4094,15 +3243,11 @@ }, "node_modules/@npmcli/arborist/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, "license": "ISC" }, "node_modules/@npmcli/config": { "version": "10.8.1", - "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-10.8.1.tgz", - "integrity": "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ==", "license": "ISC", "dependencies": { "@npmcli/map-workspaces": "^5.0.0", @@ -4120,8 +3265,6 @@ }, "node_modules/@npmcli/config/node_modules/nopt": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "dependencies": { "abbrev": "^4.0.0" @@ -4135,8 +3278,6 @@ }, "node_modules/@npmcli/fs": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", - "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", "license": "ISC", "dependencies": { "semver": "^7.3.5" @@ -4147,8 +3288,6 @@ }, "node_modules/@npmcli/git": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", - "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -4166,8 +3305,6 @@ }, "node_modules/@npmcli/git/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4175,8 +3312,6 @@ }, "node_modules/@npmcli/installed-package-contents": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", - "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", "license": "ISC", "dependencies": { "npm-bundled": "^5.0.0", @@ -4191,8 +3326,6 @@ }, "node_modules/@npmcli/map-workspaces": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-5.0.3.tgz", - "integrity": "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==", "license": "ISC", "dependencies": { "@npmcli/name-from-folder": "^4.0.0", @@ -4206,8 +3339,6 @@ }, "node_modules/@npmcli/map-workspaces/node_modules/glob": { "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -4223,8 +3354,6 @@ }, "node_modules/@npmcli/map-workspaces/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4232,8 +3361,6 @@ }, "node_modules/@npmcli/map-workspaces/node_modules/path-scurry": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -4248,8 +3375,6 @@ }, "node_modules/@npmcli/metavuln-calculator": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.3.tgz", - "integrity": "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==", "license": "ISC", "dependencies": { "cacache": "^20.0.0", @@ -4264,8 +3389,6 @@ }, "node_modules/@npmcli/name-from-folder": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-4.0.0.tgz", - "integrity": "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4273,8 +3396,6 @@ }, "node_modules/@npmcli/node-gyp": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", - "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4282,8 +3403,6 @@ }, "node_modules/@npmcli/package-json": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", - "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", "license": "ISC", "dependencies": { "@npmcli/git": "^7.0.0", @@ -4294,598 +3413,198 @@ "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/package-json/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@npmcli/package-json/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", - "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", - "license": "ISC", - "dependencies": { - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/query": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-5.0.0.tgz", - "integrity": "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==", - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", - "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", - "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "node-gyp": "^12.1.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", - "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", - "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", - "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", - "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", - "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", - "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", - "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", - "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", - "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", - "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", - "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", - "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", - "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", - "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", - "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", - "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", - "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", - "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", - "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", - "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, + "node_modules/@npmcli/package-json/node_modules/glob": { + "version": "13.0.6", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": ">=14" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "license": "MIT", + "node_modules/@npmcli/package-json/node_modules/lru-cache": { + "version": "11.2.7", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12.22.0" + "node": "20 || >=22" } }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "license": "MIT", + "node_modules/@npmcli/package-json/node_modules/path-scurry": { + "version": "2.0.2", + "license": "BlueOak-1.0.0", "dependencies": { - "graceful-fs": "4.2.10" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=12.22.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", - "license": "MIT", + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "license": "ISC", "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" + "which": "^6.0.0" }, "engines": { - "node": ">=12" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", + "node_modules/@npmcli/query": { + "version": "5.0.0", + "license": "ISC", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", + "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ] }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", + "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ] }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.19.1", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", + "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", + "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ] }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", + "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", + "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", + "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", "cpu": [ "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" + "dev": true, + "libc": [ + "glibc" ], "license": "MIT", "optional": true, @@ -4893,25 +3612,16 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", + "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", "cpu": [ - "loong64" + "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" + "dev": true, + "libc": [ + "musl" ], "license": "MIT", "optional": true, @@ -4919,25 +3629,16 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", + "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", "cpu": [ "ppc64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" + "dev": true, + "libc": [ + "glibc" ], "license": "MIT", "optional": true, @@ -4945,160 +3646,252 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", + "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", "cpu": [ "riscv64" ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", + "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", "cpu": [ "riscv64" ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", + "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", "cpu": [ "s390x" ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", + "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", "cpu": [ "x64" ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", + "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", "cpu": [ "x64" ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", + "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "openharmony" ] }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", + "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", "cpu": [ - "arm64" + "wasm32" ], + "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", + "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", + "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", + "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ] }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "dev": true, "license": "MIT" }, "node_modules/@shikijs/core": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", "license": "MIT", "dependencies": { "@shikijs/engine-javascript": "2.5.0", @@ -5111,8 +3904,6 @@ }, "node_modules/@shikijs/engine-javascript": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0", @@ -5122,8 +3913,6 @@ }, "node_modules/@shikijs/engine-oniguruma": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0", @@ -5132,8 +3921,6 @@ }, "node_modules/@shikijs/langs": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0" @@ -5141,8 +3928,6 @@ }, "node_modules/@shikijs/themes": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0" @@ -5150,8 +3935,6 @@ }, "node_modules/@shikijs/transformers": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", "license": "MIT", "dependencies": { "@shikijs/core": "2.5.0", @@ -5160,8 +3943,6 @@ }, "node_modules/@shikijs/types": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -5170,14 +3951,10 @@ }, "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, "node_modules/@sigstore/bundle": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", - "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" @@ -5188,8 +3965,6 @@ }, "node_modules/@sigstore/core": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz", - "integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==", "license": "Apache-2.0", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -5197,8 +3972,6 @@ }, "node_modules/@sigstore/protobuf-specs": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", - "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", "license": "Apache-2.0", "engines": { "node": "^18.17.0 || >=20.5.0" @@ -5206,8 +3979,6 @@ }, "node_modules/@sigstore/sign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", - "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", "license": "Apache-2.0", "dependencies": { "@gar/promise-retry": "^1.0.2", @@ -5223,8 +3994,6 @@ }, "node_modules/@sigstore/tuf": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", - "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", @@ -5236,8 +4005,6 @@ }, "node_modules/@sigstore/verify": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", - "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", @@ -5250,8 +4017,6 @@ }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", - "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", "dev": true, "license": "MIT", "dependencies": { @@ -5266,8 +4031,6 @@ }, "node_modules/@simple-libs/stream-utils": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", "dev": true, "license": "MIT", "engines": { @@ -5279,8 +4042,6 @@ }, "node_modules/@sindresorhus/base62": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", - "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", "dev": true, "license": "MIT", "engines": { @@ -5292,8 +4053,6 @@ }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "license": "MIT", "engines": { "node": ">=18" @@ -5304,8 +4063,6 @@ }, "node_modules/@sinonjs/commons": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5314,273 +4071,72 @@ }, "node_modules/@sinonjs/fake-timers": { "version": "15.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", - "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@sinonjs/samsam": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.3.tgz", - "integrity": "sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "type-detect": "^4.1.0" - } - }, - "node_modules/@sinonjs/samsam/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", - "cpu": [ - "x64" - ], + "node_modules/@sinonjs/samsam": { + "version": "9.0.3", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 20" + "node": ">=4" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "node_modules/@tailwindcss/node": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "node_modules/@tailwindcss/oxide": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", - "cpu": [ - "arm64" - ], "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "node_modules/@tailwindcss/oxide-darwin-arm64": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { "node": ">= 20" @@ -5588,8 +4144,6 @@ }, "node_modules/@tailwindcss/vite": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", "license": "MIT", "dependencies": { "@tailwindcss/node": "4.2.2", @@ -5602,15 +4156,11 @@ }, "node_modules/@tokenizer/token": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "dev": true, "license": "MIT" }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", "license": "MIT", "engines": { "node": "^16.14.0 || >=18.0.0" @@ -5618,8 +4168,6 @@ }, "node_modules/@tufjs/models": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", - "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", "license": "MIT", "dependencies": { "@tufjs/canonical-json": "2.0.0", @@ -5633,6 +4181,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5641,14 +4190,10 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/hast": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -5656,21 +4201,15 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/linkify-it": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", "dependencies": { "@types/linkify-it": "^5", @@ -5679,8 +4218,6 @@ }, "node_modules/@types/mdast": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -5688,14 +4225,10 @@ }, "node_modules/@types/mdurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, "node_modules/@types/node": { "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -5703,26 +4236,18 @@ }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, "node_modules/@types/unist": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "license": "MIT" }, "node_modules/@typescript-eslint/types": { "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -5771,14 +4296,10 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, "node_modules/@vercel/nft": { "version": "0.29.4", - "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.29.4.tgz", - "integrity": "sha512-6lLqMNX3TuycBPABycx7A9F1bHQR7kiQln6abjFbPrf5C/05qHM9M5E4PeTE59c7z8g6vHnx1Ioihb2AQl7BTA==", "dev": true, "license": "MIT", "dependencies": { @@ -5804,8 +4325,6 @@ }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -5817,8 +4336,6 @@ }, "node_modules/@vue/compiler-core": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", - "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", @@ -5830,8 +4347,6 @@ }, "node_modules/@vue/compiler-core/node_modules/entities": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5842,8 +4357,6 @@ }, "node_modules/@vue/compiler-dom": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", - "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.32", @@ -5852,8 +4365,6 @@ }, "node_modules/@vue/compiler-sfc": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", - "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", @@ -5869,8 +4380,6 @@ }, "node_modules/@vue/compiler-ssr": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", - "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.32", @@ -5879,8 +4388,6 @@ }, "node_modules/@vue/devtools-api": { "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", "license": "MIT", "dependencies": { "@vue/devtools-kit": "^7.7.9" @@ -5888,8 +4395,6 @@ }, "node_modules/@vue/devtools-kit": { "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", "license": "MIT", "dependencies": { "@vue/devtools-shared": "^7.7.9", @@ -5903,8 +4408,6 @@ }, "node_modules/@vue/devtools-shared": { "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", "license": "MIT", "dependencies": { "rfdc": "^1.4.1" @@ -5912,8 +4415,6 @@ }, "node_modules/@vue/reactivity": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", - "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", "license": "MIT", "dependencies": { "@vue/shared": "3.5.32" @@ -5921,8 +4422,6 @@ }, "node_modules/@vue/runtime-core": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", - "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.32", @@ -5931,8 +4430,6 @@ }, "node_modules/@vue/runtime-dom": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", - "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.32", @@ -5943,8 +4440,6 @@ }, "node_modules/@vue/server-renderer": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", - "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.32", @@ -5956,14 +4451,10 @@ }, "node_modules/@vue/shared": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", - "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "license": "MIT" }, "node_modules/@vueuse/core": { "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", @@ -5977,8 +4468,6 @@ }, "node_modules/@vueuse/integrations": { "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", "license": "MIT", "dependencies": { "@vueuse/core": "12.8.2", @@ -6043,8 +4532,6 @@ }, "node_modules/@vueuse/metadata": { "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -6052,8 +4539,6 @@ }, "node_modules/@vueuse/shared": { "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", "license": "MIT", "dependencies": { "vue": "^3.5.13" @@ -6064,8 +4549,6 @@ }, "node_modules/abbrev": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -6073,8 +4556,6 @@ }, "node_modules/abort-controller": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "dev": true, "license": "MIT", "dependencies": { @@ -6086,8 +4567,6 @@ }, "node_modules/accepts": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { "mime-types": "~2.1.34", @@ -6099,8 +4578,6 @@ }, "node_modules/accepts/node_modules/negotiator": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6108,8 +4585,6 @@ }, "node_modules/acorn": { "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -6120,8 +4595,6 @@ }, "node_modules/acorn-import-attributes": { "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6130,8 +4603,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6139,8 +4610,6 @@ }, "node_modules/acorn-walk": { "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -6152,8 +4621,6 @@ }, "node_modules/agent-base": { "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -6161,8 +4628,6 @@ }, "node_modules/aggregate-error": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, "license": "MIT", "dependencies": { @@ -6175,8 +4640,6 @@ }, "node_modules/aggregate-error/node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", "engines": { @@ -6185,8 +4648,6 @@ }, "node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6201,8 +4662,6 @@ }, "node_modules/ajv-errors": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", - "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", "license": "MIT", "peerDependencies": { "ajv": "^8.0.1" @@ -6210,8 +4669,6 @@ }, "node_modules/algoliasearch": { "version": "5.50.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.50.1.tgz", - "integrity": "sha512-/bwdue1/8LWELn/DBalGRfuLsXBLXULJo/yOeavJtDu8rBwxIzC6/Rz9Jg19S21VkJvRuZO1k8CZXBMS73mYbA==", "license": "MIT", "dependencies": { "@algolia/abtesting": "1.16.1", @@ -6235,8 +4692,6 @@ }, "node_modules/ansi-align": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", "license": "ISC", "dependencies": { "string-width": "^4.1.0" @@ -6244,8 +4699,6 @@ }, "node_modules/ansi-align/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -6253,14 +4706,10 @@ }, "node_modules/ansi-align/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/ansi-align/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -6268,8 +4717,6 @@ }, "node_modules/ansi-align/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6282,8 +4729,6 @@ }, "node_modules/ansi-align/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6294,8 +4739,6 @@ }, "node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -6306,8 +4749,6 @@ }, "node_modules/ansi-styles": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -6318,8 +4759,6 @@ }, "node_modules/append-transform": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", - "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", "dev": true, "license": "MIT", "dependencies": { @@ -6331,15 +4770,11 @@ }, "node_modules/archy": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true, "license": "MIT" }, "node_modules/are-docs-informative": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", "dev": true, "license": "MIT", "engines": { @@ -6348,14 +4783,10 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -6371,8 +4802,6 @@ }, "node_modules/array-find-index": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", "dev": true, "license": "MIT", "engines": { @@ -6381,21 +4810,15 @@ }, "node_modules/array-flatten": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, "node_modules/array-ify": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true, "license": "MIT" }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6416,8 +4839,6 @@ }, "node_modules/arrgv": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz", - "integrity": "sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==", "dev": true, "license": "MIT", "engines": { @@ -6426,8 +4847,6 @@ }, "node_modules/arrify": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz", - "integrity": "sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==", "dev": true, "license": "MIT", "engines": { @@ -6439,15 +4858,11 @@ }, "node_modules/asap": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true, "license": "MIT" }, "node_modules/async": { "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", "license": "MIT", "dependencies": { "lodash": "^4.17.14" @@ -6455,8 +4870,6 @@ }, "node_modules/async-function": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { @@ -6465,22 +4878,16 @@ }, "node_modules/async-sema": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", - "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", "dev": true, "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, "license": "MIT" }, "node_modules/atomically": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", "license": "MIT", "dependencies": { "stubborn-fs": "^2.0.0", @@ -6489,8 +4896,6 @@ }, "node_modules/autoprefixer": { "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "funding": [ { "type": "opencollective", @@ -6525,8 +4930,6 @@ }, "node_modules/ava": { "version": "6.4.1", - "resolved": "https://registry.npmjs.org/ava/-/ava-6.4.1.tgz", - "integrity": "sha512-vxmPbi1gZx9zhAjHBgw81w/iEDKcrokeRk/fqDTyA2DQygZ0o+dUGRHFOtX8RA5N0heGJTTsIk7+xYxitDb61Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6588,8 +4991,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6604,8 +5005,6 @@ }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6621,8 +5020,6 @@ }, "node_modules/balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -6630,8 +5027,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, "funding": [ { @@ -6651,8 +5046,6 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6663,8 +5056,6 @@ }, "node_modules/bin-links": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", - "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", "license": "ISC", "dependencies": { "cmd-shim": "^8.0.0", @@ -6679,8 +5070,6 @@ }, "node_modules/bin-links/node_modules/write-file-atomic": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", - "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "license": "ISC", "dependencies": { "signal-exit": "^4.0.1" @@ -6691,8 +5080,6 @@ }, "node_modules/bindings": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6701,8 +5088,6 @@ }, "node_modules/birpc": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -6710,21 +5095,15 @@ }, "node_modules/bluebird": { "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, "node_modules/blueimp-md5": { "version": "2.19.0", - "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", "dev": true, "license": "MIT" }, "node_modules/body-parser": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -6747,14 +5126,10 @@ }, "node_modules/boolbase": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, "node_modules/boxen": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", "license": "MIT", "dependencies": { "ansi-align": "^3.0.1", @@ -6775,8 +5150,6 @@ }, "node_modules/boxen/node_modules/camelcase": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", "license": "MIT", "engines": { "node": ">=16" @@ -6787,8 +5160,6 @@ }, "node_modules/boxen/node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -6799,8 +5170,6 @@ }, "node_modules/boxen/node_modules/wrap-ansi": { "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -6816,8 +5185,6 @@ }, "node_modules/brace-expansion": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6828,8 +5195,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6840,8 +5205,6 @@ }, "node_modules/browserslist": { "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -6873,8 +5236,6 @@ }, "node_modules/buffer": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dev": true, "funding": [ { @@ -6898,14 +5259,10 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, "node_modules/bundle-name": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -6919,8 +5276,6 @@ }, "node_modules/bytes": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -6928,8 +5283,6 @@ }, "node_modules/cacache": { "version": "20.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", - "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", "license": "ISC", "dependencies": { "@npmcli/fs": "^5.0.0", @@ -6949,8 +5302,6 @@ }, "node_modules/cacache/node_modules/glob": { "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -6966,8 +5317,6 @@ }, "node_modules/cacache/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -6975,8 +5324,6 @@ }, "node_modules/cacache/node_modules/path-scurry": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -6991,8 +5338,6 @@ }, "node_modules/caching-transform": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", - "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", "dev": true, "license": "MIT", "dependencies": { @@ -7007,8 +5352,6 @@ }, "node_modules/caching-transform/node_modules/make-dir": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "dev": true, "license": "MIT", "dependencies": { @@ -7023,8 +5366,6 @@ }, "node_modules/caching-transform/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -7033,15 +5374,11 @@ }, "node_modules/caching-transform/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, "node_modules/caching-transform/node_modules/write-file-atomic": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", "dev": true, "license": "ISC", "dependencies": { @@ -7053,8 +5390,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -7072,8 +5407,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7085,8 +5418,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7101,8 +5432,6 @@ }, "node_modules/callsites": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz", - "integrity": "sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==", "dev": true, "license": "MIT", "engines": { @@ -7114,8 +5443,6 @@ }, "node_modules/camelcase": { "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", "engines": { @@ -7124,8 +5451,6 @@ }, "node_modules/caniuse-api": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", "license": "MIT", "dependencies": { "browserslist": "^4.0.0", @@ -7136,8 +5461,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", "funding": [ { "type": "opencollective", @@ -7156,8 +5479,6 @@ }, "node_modules/catharsis": { "version": "0.9.0", - "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", - "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", "license": "MIT", "dependencies": { "lodash": "^4.17.15" @@ -7168,8 +5489,6 @@ }, "node_modules/cbor": { "version": "10.0.12", - "resolved": "https://registry.npmjs.org/cbor/-/cbor-10.0.12.tgz", - "integrity": "sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA==", "dev": true, "license": "MIT", "dependencies": { @@ -7181,8 +5500,6 @@ }, "node_modules/ccount": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "license": "MIT", "funding": { "type": "github", @@ -7191,8 +5508,6 @@ }, "node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -7203,8 +5518,6 @@ }, "node_modules/character-entities-html4": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "license": "MIT", "funding": { "type": "github", @@ -7213,8 +5526,6 @@ }, "node_modules/character-entities-legacy": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "license": "MIT", "funding": { "type": "github", @@ -7243,8 +5554,6 @@ }, "node_modules/cheerio": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", @@ -7268,8 +5577,6 @@ }, "node_modules/cheerio-select": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -7285,8 +5592,6 @@ }, "node_modules/chownr": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -7294,15 +5599,11 @@ }, "node_modules/chunkd": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz", - "integrity": "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==", "dev": true, "license": "MIT" }, "node_modules/ci-info": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "funding": [ { "type": "github", @@ -7316,15 +5617,11 @@ }, "node_modules/ci-parallel-vars": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz", - "integrity": "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==", "dev": true, "license": "MIT" }, "node_modules/clean-stack": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, "license": "MIT", "engines": { @@ -7333,8 +5630,6 @@ }, "node_modules/cli-boxes": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", "engines": { "node": ">=10" @@ -7345,8 +5640,6 @@ }, "node_modules/cli-progress": { "version": "3.12.0", - "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", - "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", "license": "MIT", "dependencies": { "string-width": "^4.2.3" @@ -7357,8 +5650,6 @@ }, "node_modules/cli-progress/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -7366,14 +5657,10 @@ }, "node_modules/cli-progress/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/cli-progress/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -7381,8 +5668,6 @@ }, "node_modules/cli-progress/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7395,8 +5680,6 @@ }, "node_modules/cli-progress/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7407,8 +5690,6 @@ }, "node_modules/cli-truncate": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, "license": "MIT", "dependencies": { @@ -7424,8 +5705,6 @@ }, "node_modules/cliui": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", "dependencies": { @@ -7439,8 +5718,6 @@ }, "node_modules/cliui/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -7449,8 +5726,6 @@ }, "node_modules/cliui/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -7465,15 +5740,11 @@ }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -7482,8 +5753,6 @@ }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -7497,8 +5766,6 @@ }, "node_modules/cliui/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -7510,8 +5777,6 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7528,8 +5793,6 @@ }, "node_modules/clone": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", "license": "MIT", "engines": { "node": ">=0.8" @@ -7537,8 +5800,6 @@ }, "node_modules/cmd-shim": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", - "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -7546,8 +5807,6 @@ }, "node_modules/code-excerpt": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "dev": true, "license": "MIT", "dependencies": { @@ -7559,8 +5818,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7571,14 +5828,10 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "license": "MIT", "dependencies": { @@ -7590,8 +5843,6 @@ }, "node_modules/comma-separated-tokens": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "license": "MIT", "funding": { "type": "github", @@ -7600,14 +5851,10 @@ }, "node_modules/command-exists": { "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", "license": "MIT" }, "node_modules/commander": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, "license": "MIT", "engines": { @@ -7616,8 +5863,6 @@ }, "node_modules/comment-parser": { "version": "1.4.6", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", - "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", "dev": true, "license": "MIT", "engines": { @@ -7626,8 +5871,6 @@ }, "node_modules/common-ancestor-path": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", - "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", "license": "BlueOak-1.0.0", "engines": { "node": ">= 18" @@ -7635,22 +5878,16 @@ }, "node_modules/common-path-prefix": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "dev": true, "license": "ISC" }, "node_modules/commondir": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true, "license": "MIT" }, "node_modules/compare-func": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", "dev": true, "license": "MIT", "dependencies": { @@ -7660,8 +5897,6 @@ }, "node_modules/component-emitter": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", "dev": true, "license": "MIT", "funding": { @@ -7670,8 +5905,6 @@ }, "node_modules/compressible": { "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" @@ -7682,8 +5915,6 @@ }, "node_modules/compression": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { "bytes": "3.1.2", @@ -7700,8 +5931,6 @@ }, "node_modules/compression/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -7709,14 +5938,10 @@ }, "node_modules/compression/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/compression/node_modules/negotiator": { "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7724,15 +5949,11 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/concordance": { "version": "5.0.4", - "resolved": "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz", - "integrity": "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==", "dev": true, "license": "ISC", "dependencies": { @@ -7751,8 +5972,6 @@ }, "node_modules/config-chain": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "license": "MIT", "dependencies": { "ini": "^1.3.4", @@ -7761,14 +5980,10 @@ }, "node_modules/config-chain/node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/configstore": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", - "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", "license": "BSD-2-Clause", "dependencies": { "atomically": "^2.0.3", @@ -7785,8 +6000,6 @@ }, "node_modules/configstore/node_modules/dot-prop": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "license": "MIT", "dependencies": { "type-fest": "^4.18.2" @@ -7800,8 +6013,6 @@ }, "node_modules/configstore/node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -7812,8 +6023,6 @@ }, "node_modules/consola": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "dev": true, "license": "MIT", "engines": { @@ -7822,8 +6031,6 @@ }, "node_modules/content-disposition": { "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -7834,8 +6041,6 @@ }, "node_modules/content-type": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7843,8 +6048,6 @@ }, "node_modules/conventional-changelog-angular": { "version": "8.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", - "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", "dev": true, "license": "ISC", "dependencies": { @@ -7856,8 +6059,6 @@ }, "node_modules/conventional-changelog-conventionalcommits": { "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", "dev": true, "license": "ISC", "dependencies": { @@ -7869,8 +6070,6 @@ }, "node_modules/conventional-commits-parser": { "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", "dependencies": { @@ -7886,15 +6085,11 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "node_modules/convert-to-spaces": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "dev": true, "license": "MIT", "engines": { @@ -7903,8 +6098,6 @@ }, "node_modules/cookie": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7912,21 +6105,15 @@ }, "node_modules/cookie-signature": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, "node_modules/cookiejar": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true, "license": "MIT" }, "node_modules/copy-anything": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", "license": "MIT", "dependencies": { "is-what": "^5.2.0" @@ -7940,14 +6127,10 @@ }, "node_modules/core-util-is": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, "node_modules/correct-license-metadata": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/correct-license-metadata/-/correct-license-metadata-1.5.0.tgz", - "integrity": "sha512-fVBH+P7EJvvzqQ1Jn7xrdAD7tKFrjeBDNawOgNELcSopCL70Ie8H9Cyn1nYO0E7jihunnpqjWdpEQinDhhKrzw==", "dev": true, "license": "MIT", "dependencies": { @@ -7956,8 +6139,6 @@ }, "node_modules/cors": { "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -7973,8 +6154,6 @@ }, "node_modules/cosmiconfig": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8000,8 +6179,6 @@ }, "node_modules/cosmiconfig-typescript-loader": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", - "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8018,8 +6195,6 @@ }, "node_modules/cross-env": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "dev": true, "license": "MIT", "dependencies": { @@ -8036,8 +6211,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8050,14 +6223,10 @@ }, "node_modules/cross-spawn/node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8071,8 +6240,6 @@ }, "node_modules/crypto-random-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", "dev": true, "license": "MIT", "dependencies": { @@ -8087,8 +6254,6 @@ }, "node_modules/crypto-random-string/node_modules/type-fest": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -8100,8 +6265,6 @@ }, "node_modules/css-declaration-sorter": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", - "integrity": "sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==", "license": "ISC", "engines": { "node": "^14 || ^16 || >=18" @@ -8112,8 +6275,6 @@ }, "node_modules/css-select": { "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -8128,8 +6289,6 @@ }, "node_modules/css-tree": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -8141,8 +6300,6 @@ }, "node_modules/css-what": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -8153,8 +6310,6 @@ }, "node_modules/cssesc": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -8165,8 +6320,6 @@ }, "node_modules/cssnano": { "version": "7.1.4", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.4.tgz", - "integrity": "sha512-T9PNS7y+5Nc9Qmu9mRONqfxG1RVY7Vuvky0XN6MZ+9hqplesTEwnj9r0ROtVuSwUVfaDhVlavuzWIVLUgm4hkQ==", "license": "MIT", "dependencies": { "cssnano-preset-default": "^7.0.12", @@ -8185,8 +6338,6 @@ }, "node_modules/cssnano-preset-default": { "version": "7.0.12", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.12.tgz", - "integrity": "sha512-B3Eoouzw/sl2zANI0AL9KbacummJTCww+fkHaDBMZad/xuVx8bUduPLly6hKVQAlrmvYkS1jB1CVQEKm3gn0AA==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -8229,8 +6380,6 @@ }, "node_modules/cssnano-utils": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.1.tgz", - "integrity": "sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" @@ -8241,8 +6390,6 @@ }, "node_modules/csso": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", "license": "MIT", "dependencies": { "css-tree": "~2.2.0" @@ -8254,8 +6401,6 @@ }, "node_modules/csso/node_modules/css-tree": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", "license": "MIT", "dependencies": { "mdn-data": "2.0.28", @@ -8268,20 +6413,14 @@ }, "node_modules/csso/node_modules/mdn-data": { "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/currently-unhandled": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", "dev": true, "license": "MIT", "dependencies": { @@ -8293,8 +6432,6 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8311,8 +6448,6 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8329,8 +6464,6 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8347,8 +6480,6 @@ }, "node_modules/data-with-position": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/data-with-position/-/data-with-position-0.5.0.tgz", - "integrity": "sha512-GhsgEIPWk7WCAisjwBkOjvPqpAlVUOSl1CTmy9KyhVMG1wxl29Zj5+J71WhQ/KgoJS/Psxq6Cnioz3xdBjeIWQ==", "license": "BSD-3-Clause", "dependencies": { "yaml-ast-parser": "^0.0.43" @@ -8356,8 +6487,6 @@ }, "node_modules/date-time": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", - "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==", "dev": true, "license": "MIT", "dependencies": { @@ -8369,8 +6498,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8386,8 +6513,6 @@ }, "node_modules/decamelize": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true, "license": "MIT", "engines": { @@ -8396,8 +6521,6 @@ }, "node_modules/deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -8405,15 +6528,11 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/default-browser": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -8428,8 +6547,6 @@ }, "node_modules/default-browser-id": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "license": "MIT", "engines": { "node": ">=18" @@ -8440,8 +6557,6 @@ }, "node_modules/default-require-extensions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", - "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", "dev": true, "license": "MIT", "dependencies": { @@ -8456,8 +6571,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -8474,8 +6587,6 @@ }, "node_modules/define-lazy-prop": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "license": "MIT", "engines": { "node": ">=12" @@ -8486,8 +6597,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -8504,8 +6613,6 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", "engines": { @@ -8514,8 +6621,6 @@ }, "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8523,8 +6628,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { "node": ">=6" @@ -8532,8 +6635,6 @@ }, "node_modules/destroy": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", "engines": { "node": ">= 0.8", @@ -8542,8 +6643,6 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -8551,14 +6650,10 @@ }, "node_modules/detect-node": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, "node_modules/devcert-sanscache": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/devcert-sanscache/-/devcert-sanscache-0.5.1.tgz", - "integrity": "sha512-9ePmMvWItstun0c35V5WXUlNU4MCHtpXWxKUJcDiZvyKkcA3FxkL6PFHKqTd446mXMmvLpOGBxVD6GjBXeMA5A==", "license": "MIT", "dependencies": { "command-exists": "^1.2.9", @@ -8572,8 +6667,6 @@ }, "node_modules/devcert-sanscache/node_modules/rimraf": { "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "license": "ISC", "dependencies": { "glob": "^10.3.7" @@ -8587,8 +6680,6 @@ }, "node_modules/devlop": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -8600,8 +6691,6 @@ }, "node_modules/dezalgo": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, "license": "ISC", "dependencies": { @@ -8611,8 +6700,6 @@ }, "node_modules/diff": { "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -8621,8 +6708,6 @@ }, "node_modules/docopt": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/docopt/-/docopt-0.6.2.tgz", - "integrity": "sha512-NqTbaYeE4gA/wU1hdKFdU+AFahpDOpgGLzHP42k6H6DKExJd0A55KEVWYhL9FEmHmgeLvEU2vuKXDuU+4yToOw==", "dev": true, "engines": { "node": ">=0.10.0" @@ -8630,8 +6715,6 @@ }, "node_modules/dom-serializer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -8644,8 +6727,6 @@ }, "node_modules/domelementtype": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -8656,8 +6737,6 @@ }, "node_modules/domhandler": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -8671,8 +6750,6 @@ }, "node_modules/domutils": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -8685,8 +6762,6 @@ }, "node_modules/dot-prop": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8698,8 +6773,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8712,14 +6785,10 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, "node_modules/editorconfig": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "dev": true, "license": "MIT", "dependencies": { @@ -8737,15 +6806,11 @@ }, "node_modules/editorconfig/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/editorconfig/node_modules/brace-expansion": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -8754,8 +6819,6 @@ }, "node_modules/editorconfig/node_modules/minimatch": { "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { @@ -8770,20 +6833,14 @@ }, "node_modules/ee-first": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "license": "ISC" }, "node_modules/emittery": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-1.2.1.tgz", - "integrity": "sha512-sFz64DCRjirhwHLxofFqxYQm6DCp6o0Ix7jwKQvuCHPn4GMRZNuBZyLPu9Ccmk/QSCAMZt6FOUqA8JZCQvA9fw==", "dev": true, "license": "MIT", "engines": { @@ -8795,20 +6852,14 @@ }, "node_modules/emoji-regex": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/emoji-regex-xs": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8816,8 +6867,6 @@ }, "node_modules/encoding": { "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "dev": true, "license": "MIT", "optional": true, @@ -8827,8 +6876,6 @@ }, "node_modules/encoding-sniffer": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "license": "MIT", "dependencies": { "iconv-lite": "^0.6.3", @@ -8840,8 +6887,6 @@ }, "node_modules/encoding-sniffer/node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8852,8 +6897,6 @@ }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "optional": true, @@ -8866,8 +6909,6 @@ }, "node_modules/enhance-visitors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/enhance-visitors/-/enhance-visitors-1.0.0.tgz", - "integrity": "sha512-+29eJLiUixTEDRaZ35Vu8jP3gPLNcQQkQkOQjLp2X+6cZGGPDD/uasbFzvLsJKnGZnvmyZ0srxudwOtskHeIDA==", "dev": true, "license": "MIT", "dependencies": { @@ -8879,8 +6920,6 @@ }, "node_modules/enhanced-resolve": { "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8892,8 +6931,6 @@ }, "node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -8904,8 +6941,6 @@ }, "node_modules/env-paths": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "license": "MIT", "engines": { "node": ">=6" @@ -8913,15 +6948,11 @@ }, "node_modules/err-code": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true, "license": "MIT" }, "node_modules/error-ex": { "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8930,8 +6961,6 @@ }, "node_modules/es-abstract": { "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -8999,8 +7028,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9008,8 +7035,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9017,8 +7042,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9029,8 +7052,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -9045,8 +7066,6 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { @@ -9063,15 +7082,11 @@ }, "node_modules/es6-error": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -9108,8 +7123,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -9117,8 +7130,6 @@ }, "node_modules/escape-goat": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "license": "MIT", "engines": { "node": ">=12" @@ -9129,14 +7140,10 @@ }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { "node": ">=12" @@ -9147,8 +7154,6 @@ }, "node_modules/escape-unicode": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/escape-unicode/-/escape-unicode-0.3.0.tgz", - "integrity": "sha512-4Lr9Prysw8FBwpW8dURr4T3/VRU4RYlhayLgy34zavplBG9bUsTtaCuM7Lw3szWTuidQvkZ2a1qJxG3e5+o99w==", "funding": [ { "type": "individual", @@ -9163,8 +7168,6 @@ }, "node_modules/escope": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escope/-/escope-4.0.0.tgz", - "integrity": "sha512-E36qlD/r6RJHVpPKArgMoMlNJzoRJFH8z/cAZlI9lbc45zB3+S7i9k6e/MNb+7bZQzNEa6r8WKN3BovpeIBwgA==", "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.1.0", @@ -9176,8 +7179,6 @@ }, "node_modules/escope/node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -9185,8 +7186,6 @@ }, "node_modules/eslint": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9245,8 +7244,6 @@ }, "node_modules/eslint-config-google": { "version": "0.14.0", - "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", - "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9258,8 +7255,6 @@ }, "node_modules/eslint-plugin-ava": { "version": "15.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-ava/-/eslint-plugin-ava-15.1.0.tgz", - "integrity": "sha512-+6Zxk1uYW3mf7lxCLWIQsFYgn3hfuCMbsKc0MtqfloOz1F6fiV5/PaWEaLgkL1egrSQmnyR7vOFP1wSPJbVUbw==", "dev": true, "license": "MIT", "dependencies": { @@ -9281,8 +7276,6 @@ }, "node_modules/eslint-plugin-ava/node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9294,8 +7287,6 @@ }, "node_modules/eslint-plugin-ava/node_modules/espree": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9312,8 +7303,6 @@ }, "node_modules/eslint-plugin-jsdoc": { "version": "62.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", - "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9341,8 +7330,6 @@ }, "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -9354,8 +7341,6 @@ }, "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9367,8 +7352,6 @@ }, "node_modules/eslint-plugin-jsdoc/node_modules/espree": { "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9385,8 +7368,6 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9402,8 +7383,6 @@ }, "node_modules/eslint-utils": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, "license": "MIT", "dependencies": { @@ -9421,8 +7400,6 @@ }, "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9431,8 +7408,6 @@ }, "node_modules/eslint-visitor-keys": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9443,8 +7418,6 @@ }, "node_modules/eslint/node_modules/ajv": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -9460,8 +7433,6 @@ }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -9476,15 +7447,11 @@ }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -9494,8 +7461,6 @@ }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -9511,8 +7476,6 @@ }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -9524,8 +7487,6 @@ }, "node_modules/eslint/node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -9541,15 +7502,11 @@ }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -9564,8 +7521,6 @@ }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -9577,8 +7532,6 @@ }, "node_modules/eslint/node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9593,8 +7546,6 @@ }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -9609,8 +7560,6 @@ }, "node_modules/esmock": { "version": "2.7.3", - "resolved": "https://registry.npmjs.org/esmock/-/esmock-2.7.3.tgz", - "integrity": "sha512-/M/YZOjgyLaVoY6K83pwCsGE1AJQnj4S4GyXLYgi/Y79KL8EeW6WU7Rmjc89UO7jv6ec8+j34rKeWOfiLeEu0A==", "dev": true, "license": "ISC", "engines": { @@ -9619,8 +7568,6 @@ }, "node_modules/espree": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", @@ -9636,8 +7583,6 @@ }, "node_modules/esprima": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", "bin": { @@ -9650,15 +7595,11 @@ }, "node_modules/espurify": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/espurify/-/espurify-2.1.1.tgz", - "integrity": "sha512-zttWvnkhcDyGOhSH4vO2qCBILpdCMv/MX8lp4cqgRkQoDRGK2oZxi2GfWhlP2dIXmk7BaKeOTuzbHhyC68o8XQ==", "dev": true, "license": "MIT" }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9670,8 +7611,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -9682,8 +7621,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -9691,14 +7628,10 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -9707,8 +7640,6 @@ }, "node_modules/etag": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -9716,8 +7647,6 @@ }, "node_modules/event-target-shim": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "dev": true, "license": "MIT", "engines": { @@ -9726,8 +7655,6 @@ }, "node_modules/events": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", "engines": { @@ -9736,8 +7663,6 @@ }, "node_modules/execa": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -9763,14 +7688,10 @@ }, "node_modules/exponential-backoff": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, "node_modules/express": { "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -9815,8 +7736,6 @@ }, "node_modules/express/node_modules/body-parser": { "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -9839,8 +7758,6 @@ }, "node_modules/express/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -9848,8 +7765,6 @@ }, "node_modules/express/node_modules/iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -9860,8 +7775,6 @@ }, "node_modules/express/node_modules/media-typer": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -9869,14 +7782,10 @@ }, "node_modules/express/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/express/node_modules/qs": { "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -9890,8 +7799,6 @@ }, "node_modules/express/node_modules/raw-body": { "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -9905,8 +7812,6 @@ }, "node_modules/express/node_modules/type-is": { "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { "media-typer": "0.3.0", @@ -9918,21 +7823,15 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true, "license": "Apache-2.0" }, "node_modules/fast-glob": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -9947,8 +7846,6 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -9959,29 +7856,21 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -9996,8 +7885,6 @@ }, "node_modules/fastq": { "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -10005,8 +7892,6 @@ }, "node_modules/fd-package-json": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", - "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10015,8 +7900,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10032,8 +7915,6 @@ }, "node_modules/figures": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0" @@ -10047,8 +7928,6 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10060,8 +7939,6 @@ }, "node_modules/file-type": { "version": "18.7.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", - "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", "dev": true, "license": "MIT", "dependencies": { @@ -10078,15 +7955,11 @@ }, "node_modules/file-uri-to-path": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "dev": true, "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -10097,8 +7970,6 @@ }, "node_modules/finalhandler": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -10115,8 +7986,6 @@ }, "node_modules/finalhandler/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -10124,14 +7993,10 @@ }, "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/find-cache-dir": { "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", "dev": true, "license": "MIT", "dependencies": { @@ -10148,8 +8013,6 @@ }, "node_modules/find-cache-dir/node_modules/make-dir": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "dev": true, "license": "MIT", "dependencies": { @@ -10164,8 +8027,6 @@ }, "node_modules/find-cache-dir/node_modules/pkg-dir": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10177,8 +8038,6 @@ }, "node_modules/find-cache-dir/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -10187,8 +8046,6 @@ }, "node_modules/find-up": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -10200,8 +8057,6 @@ }, "node_modules/find-up-simple": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "license": "MIT", "engines": { "node": ">=18" @@ -10212,8 +8067,6 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -10226,15 +8079,11 @@ }, "node_modules/flatted": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/focus-trap": { "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "license": "MIT", "dependencies": { "tabbable": "^6.4.0" @@ -10242,8 +8091,6 @@ }, "node_modules/for-each": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -10258,8 +8105,6 @@ }, "node_modules/foreground-child": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -10274,8 +8119,6 @@ }, "node_modules/form-data": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -10291,8 +8134,6 @@ }, "node_modules/formatly": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", - "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", "dev": true, "license": "MIT", "dependencies": { @@ -10307,8 +8148,6 @@ }, "node_modules/formidable": { "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "license": "MIT", "dependencies": { @@ -10325,8 +8164,6 @@ }, "node_modules/forwarded": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -10334,8 +8171,6 @@ }, "node_modules/fraction.js": { "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "license": "MIT", "engines": { "node": "*" @@ -10347,8 +8182,6 @@ }, "node_modules/fresh": { "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -10356,8 +8189,6 @@ }, "node_modules/fromentries": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", - "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", "dev": true, "funding": [ { @@ -10377,8 +8208,6 @@ }, "node_modules/fs-minipass": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -10389,16 +8218,11 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -10410,8 +8234,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10419,8 +8241,6 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -10440,8 +8260,6 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -10450,8 +8268,6 @@ }, "node_modules/generator-function": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", "engines": { @@ -10460,8 +8276,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { @@ -10470,8 +8284,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10479,8 +8291,6 @@ }, "node_modules/get-east-asian-width": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -10491,8 +8301,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10515,8 +8323,6 @@ }, "node_modules/get-package-type": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, "license": "MIT", "engines": { @@ -10525,8 +8331,6 @@ }, "node_modules/get-port": { "version": "6.1.2", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-6.1.2.tgz", - "integrity": "sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -10537,8 +8341,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10550,8 +8352,6 @@ }, "node_modules/get-stdin": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", - "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", "dev": true, "license": "MIT", "engines": { @@ -10563,8 +8363,6 @@ }, "node_modules/get-stream": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", "dependencies": { @@ -10580,8 +8378,6 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { @@ -10598,8 +8394,6 @@ }, "node_modules/git-raw-commits": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", - "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10615,9 +8409,6 @@ }, "node_modules/glob": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -10636,8 +8427,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -10649,14 +8438,10 @@ }, "node_modules/glob/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10664,8 +8449,6 @@ }, "node_modules/glob/node_modules/minimatch": { "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -10679,8 +8462,6 @@ }, "node_modules/global-directory": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "license": "MIT", "dependencies": { "ini": "4.1.1" @@ -10694,8 +8475,6 @@ }, "node_modules/global-directory/node_modules/ini": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -10703,8 +8482,6 @@ }, "node_modules/globals": { "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -10716,8 +8493,6 @@ }, "node_modules/globalthis": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10733,8 +8508,6 @@ }, "node_modules/globby": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", @@ -10753,8 +8526,6 @@ }, "node_modules/globby/node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "license": "MIT", "engines": { "node": ">=18" @@ -10765,8 +8536,6 @@ }, "node_modules/globby/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" @@ -10774,8 +8543,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -10786,20 +8553,14 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/handle-thing": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", "license": "MIT" }, "node_modules/handlebars": { "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10820,8 +8581,6 @@ }, "node_modules/has-bigints": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -10833,8 +8592,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -10843,8 +8600,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -10856,8 +8611,6 @@ }, "node_modules/has-proto": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10872,8 +8625,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -10884,8 +8635,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { @@ -10900,8 +8649,6 @@ }, "node_modules/hasha": { "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10917,8 +8664,6 @@ }, "node_modules/hasha/node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { @@ -10930,8 +8675,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10942,8 +8685,6 @@ }, "node_modules/hast-util-to-html": { "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -10965,8 +8706,6 @@ }, "node_modules/hast-util-whitespace": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -10978,14 +8717,10 @@ }, "node_modules/hookable": { "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, "node_modules/hosted-git-info": { "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", "license": "ISC", "dependencies": { "lru-cache": "^11.1.0" @@ -10996,8 +8731,6 @@ }, "node_modules/hosted-git-info/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11005,8 +8738,6 @@ }, "node_modules/hpack.js": { "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", "license": "MIT", "dependencies": { "inherits": "^2.0.1", @@ -11017,8 +8748,6 @@ }, "node_modules/hpack.js/node_modules/readable-stream": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -11032,14 +8761,10 @@ }, "node_modules/hpack.js/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, "node_modules/hpack.js/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -11047,8 +8772,6 @@ }, "node_modules/html-entities": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", "dev": true, "funding": [ { @@ -11064,15 +8787,11 @@ }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/html-void-elements": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "license": "MIT", "funding": { "type": "github", @@ -11081,8 +8800,6 @@ }, "node_modules/htmlparser2": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -11100,20 +8817,14 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, "node_modules/http-deceiver": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -11132,8 +8843,6 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -11145,8 +8854,6 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -11158,8 +8865,6 @@ }, "node_modules/human-signals": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -11168,8 +8873,6 @@ }, "node_modules/husky": { "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "license": "MIT", "bin": { @@ -11184,8 +8887,6 @@ }, "node_modules/iconv-lite": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -11200,8 +8901,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "dev": true, "funding": [ { @@ -11221,8 +8920,6 @@ }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -11231,8 +8928,6 @@ }, "node_modules/ignore-by-default": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.1.0.tgz", - "integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==", "dev": true, "license": "ISC", "engines": { @@ -11241,8 +8936,6 @@ }, "node_modules/ignore-walk": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", - "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", "license": "ISC", "dependencies": { "minimatch": "^10.0.3" @@ -11253,8 +8946,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11270,8 +8961,6 @@ }, "node_modules/import-fresh/node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -11280,8 +8969,6 @@ }, "node_modules/import-local": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", @@ -11299,8 +8986,6 @@ }, "node_modules/import-local/node_modules/pkg-dir": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -11311,8 +8996,6 @@ }, "node_modules/import-meta-resolve": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", "funding": { @@ -11322,8 +9005,6 @@ }, "node_modules/import-modules": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/import-modules/-/import-modules-2.1.0.tgz", - "integrity": "sha512-8HEWcnkbGpovH9yInoisxaSoIg9Brbul+Ju3Kqe2UsYDUBJD/iQjSgEj0zPcTDPKfPp2fs5xlv1i+JSye/m1/A==", "dev": true, "license": "MIT", "engines": { @@ -11335,8 +9016,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -11345,8 +9024,6 @@ }, "node_modules/indent-string": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, "license": "MIT", "engines": { @@ -11358,8 +9035,6 @@ }, "node_modules/index-to-position": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "license": "MIT", "engines": { "node": ">=18" @@ -11370,9 +9045,6 @@ }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { @@ -11382,14 +9054,10 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -11397,8 +9065,6 @@ }, "node_modules/internal-slot": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -11412,8 +9078,6 @@ }, "node_modules/ip-address": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -11421,8 +9085,6 @@ }, "node_modules/ipaddr.js": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -11430,8 +9092,6 @@ }, "node_modules/irregular-plurals": { "version": "3.5.0", - "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", - "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", "dev": true, "license": "MIT", "engines": { @@ -11440,8 +9100,6 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -11458,15 +9116,11 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11485,8 +9139,6 @@ }, "node_modules/is-bigint": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11501,8 +9153,6 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -11518,8 +9168,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -11531,8 +9179,6 @@ }, "node_modules/is-core-module": { "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -11546,8 +9192,6 @@ }, "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { @@ -11564,8 +9208,6 @@ }, "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -11581,8 +9223,6 @@ }, "node_modules/is-docker": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "license": "MIT", "bin": { "is-docker": "cli.js" @@ -11596,8 +9236,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11605,8 +9243,6 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { @@ -11621,8 +9257,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "dev": true, "license": "MIT", "engines": { @@ -11634,8 +9268,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { @@ -11654,8 +9286,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -11666,8 +9296,6 @@ }, "node_modules/is-in-ci": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -11681,8 +9309,6 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -11699,8 +9325,6 @@ }, "node_modules/is-installed-globally": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", - "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "license": "MIT", "dependencies": { "global-directory": "^4.0.1", @@ -11715,15 +9339,11 @@ }, "node_modules/is-lambda": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "dev": true, "license": "MIT" }, "node_modules/is-map": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -11735,8 +9355,6 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -11748,8 +9366,6 @@ }, "node_modules/is-npm": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", - "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -11760,8 +9376,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -11769,8 +9383,6 @@ }, "node_modules/is-number-like": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/is-number-like/-/is-number-like-1.0.8.tgz", - "integrity": "sha512-6rZi3ezCyFcn5L71ywzz2bS5b2Igl1En3eTlZlvKjpz1n3IZLAYMbKYAIQgFmEu0GENg92ziU/faEOA/aixjbA==", "license": "ISC", "dependencies": { "lodash.isfinite": "^3.3.2" @@ -11778,8 +9390,6 @@ }, "node_modules/is-number-object": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -11795,8 +9405,6 @@ }, "node_modules/is-obj": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, "license": "MIT", "engines": { @@ -11805,8 +9413,6 @@ }, "node_modules/is-path-inside": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "license": "MIT", "engines": { "node": ">=12" @@ -11817,8 +9423,6 @@ }, "node_modules/is-plain-obj": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", "engines": { @@ -11830,8 +9434,6 @@ }, "node_modules/is-plain-object": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, "license": "MIT", "engines": { @@ -11840,14 +9442,10 @@ }, "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -11865,8 +9463,6 @@ }, "node_modules/is-regexp": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", "dev": true, "license": "MIT", "engines": { @@ -11875,8 +9471,6 @@ }, "node_modules/is-set": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -11888,8 +9482,6 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -11904,8 +9496,6 @@ }, "node_modules/is-stream": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { @@ -11917,8 +9507,6 @@ }, "node_modules/is-string": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -11934,8 +9522,6 @@ }, "node_modules/is-symbol": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -11952,8 +9538,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11968,15 +9552,11 @@ }, "node_modules/is-typedarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true, "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "license": "MIT", "engines": { "node": ">=18" @@ -11987,8 +9567,6 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -12000,8 +9578,6 @@ }, "node_modules/is-weakref": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -12016,8 +9592,6 @@ }, "node_modules/is-weakset": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12033,8 +9607,6 @@ }, "node_modules/is-what": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", "license": "MIT", "engines": { "node": ">=18" @@ -12045,8 +9617,6 @@ }, "node_modules/is-windows": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true, "license": "MIT", "engines": { @@ -12055,8 +9625,6 @@ }, "node_modules/is-wsl": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -12070,14 +9638,10 @@ }, "node_modules/isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, "node_modules/isexe": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=20" @@ -12085,8 +9649,6 @@ }, "node_modules/isobject": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", "dev": true, "license": "MIT", "dependencies": { @@ -12098,8 +9660,6 @@ }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12108,8 +9668,6 @@ }, "node_modules/istanbul-lib-hook": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", - "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12121,8 +9679,6 @@ }, "node_modules/istanbul-lib-instrument": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12138,8 +9694,6 @@ }, "node_modules/istanbul-lib-instrument/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -12148,8 +9702,6 @@ }, "node_modules/istanbul-lib-processinfo": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", - "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", "dev": true, "license": "ISC", "dependencies": { @@ -12166,15 +9718,11 @@ }, "node_modules/istanbul-lib-processinfo/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -12184,9 +9732,6 @@ }, "node_modules/istanbul-lib-processinfo/node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -12206,8 +9751,6 @@ }, "node_modules/istanbul-lib-processinfo/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -12219,8 +9762,6 @@ }, "node_modules/istanbul-lib-processinfo/node_modules/p-map": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12232,9 +9773,6 @@ }, "node_modules/istanbul-lib-processinfo/node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -12249,8 +9787,6 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12264,8 +9800,6 @@ }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12279,8 +9813,6 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12293,8 +9825,6 @@ }, "node_modules/jackspeak": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -12308,8 +9838,6 @@ }, "node_modules/jiti": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -12317,8 +9845,6 @@ }, "node_modules/js-beautify": { "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", "dev": true, "license": "MIT", "dependencies": { @@ -12339,8 +9865,6 @@ }, "node_modules/js-beautify/node_modules/abbrev": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "dev": true, "license": "ISC", "engines": { @@ -12349,8 +9873,6 @@ }, "node_modules/js-beautify/node_modules/nopt": { "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "dev": true, "license": "ISC", "dependencies": { @@ -12365,8 +9887,6 @@ }, "node_modules/js-cookie": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", "dev": true, "license": "MIT", "engines": { @@ -12375,8 +9895,6 @@ }, "node_modules/js-string-escape": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", - "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", "dev": true, "license": "MIT", "engines": { @@ -12385,14 +9903,10 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12403,8 +9917,6 @@ }, "node_modules/js2xmlparser": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", - "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", "license": "Apache-2.0", "dependencies": { "xmlcreate": "^2.0.4" @@ -12412,8 +9924,6 @@ }, "node_modules/jsdoc": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", - "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", "license": "Apache-2.0", "dependencies": { "@babel/parser": "^7.20.15", @@ -12441,8 +9951,6 @@ }, "node_modules/jsdoc-type-pratt-parser": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", - "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", "dev": true, "license": "MIT", "engines": { @@ -12451,8 +9959,6 @@ }, "node_modules/jsdoc/node_modules/escape-string-regexp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", "engines": { "node": ">=8" @@ -12460,8 +9966,6 @@ }, "node_modules/jsesc": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -12473,15 +9977,11 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", - "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", "license": "MIT", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -12489,21 +9989,15 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json-stringify-nice": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", - "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", "license": "ISC", "funding": { "url": "https://github.com/sponsors/isaacs" @@ -12511,8 +10005,6 @@ }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { @@ -12524,8 +10016,6 @@ }, "node_modules/jsonparse": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "engines": [ "node >= 0.2.0" ], @@ -12533,20 +10023,14 @@ }, "node_modules/just-diff": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", - "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", "license": "MIT" }, "node_modules/just-diff-apply": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", - "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -12555,8 +10039,6 @@ }, "node_modules/klaw": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", - "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", "license": "MIT", "dependencies": { "graceful-fs": "^4.1.9" @@ -12564,8 +10046,6 @@ }, "node_modules/knip": { "version": "5.88.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.88.1.tgz", - "integrity": "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==", "dev": true, "funding": [ { @@ -12607,8 +10087,6 @@ }, "node_modules/knip/node_modules/strip-json-comments": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", "engines": { @@ -12620,8 +10098,6 @@ }, "node_modules/ky": { "version": "1.14.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", - "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", "license": "MIT", "engines": { "node": ">=18" @@ -12632,8 +10108,6 @@ }, "node_modules/latest-version": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", - "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", "license": "MIT", "dependencies": { "package-json": "^10.0.0" @@ -12647,8 +10121,6 @@ }, "node_modules/less-openui5": { "version": "0.11.6", - "resolved": "https://registry.npmjs.org/less-openui5/-/less-openui5-0.11.6.tgz", - "integrity": "sha512-sQmU+G2pJjFfzRI+XtXkk+T9G0s6UmWWUfOW0utPR46C9lfhNr4DH1lNJuImj64reXYi+vOwyNxPRkj0F3mofA==", "license": "Apache-2.0", "dependencies": { "@adobe/css-tools": "^4.0.2", @@ -12662,8 +10134,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12676,8 +10146,6 @@ }, "node_modules/licensee": { "version": "11.1.1", - "resolved": "https://registry.npmjs.org/licensee/-/licensee-11.1.1.tgz", - "integrity": "sha512-FpgdKKjvJULlBqYiKtrK7J4Oo7sQO1lHQTUOcxxE4IPQccx6c0tJWMgwVdG46+rPnLPSV7EWD6eWUtAjGO52Lg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -12702,8 +10170,6 @@ }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -12729,30 +10195,8 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-darwin-arm64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -12769,190 +10213,8 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "license": "MIT", "engines": { "node": ">=14" @@ -12963,8 +10225,6 @@ }, "node_modules/line-column": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/line-column/-/line-column-1.0.2.tgz", - "integrity": "sha512-Ktrjk5noGYlHsVnYWh62FLVs4hTb8A3e+vucNZMgPeAOITdshMSgv4cCZQeRDjm7+goqmo6+liZwTXo+U3sVww==", "dev": true, "license": "MIT", "dependencies": { @@ -12974,15 +10234,11 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, "license": "MIT" }, "node_modules/linkify-it": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -12990,8 +10246,6 @@ }, "node_modules/load-json-file": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz", - "integrity": "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==", "dev": true, "license": "MIT", "engines": { @@ -13003,8 +10257,6 @@ }, "node_modules/locate-path": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -13015,8 +10267,6 @@ }, "node_modules/lockfile": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", - "integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==", "license": "ISC", "dependencies": { "signal-exit": "^3.0.2" @@ -13024,94 +10274,66 @@ }, "node_modules/lockfile/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/lodash": { "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true, "license": "MIT" }, "node_modules/lodash.flattendeep": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.isfinite": { "version": "3.3.2", - "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", - "integrity": "sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==", "license": "MIT" }, "node_modules/lodash.kebabcase": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.mergewith": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.snakecase": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "dev": true, "license": "MIT" }, "node_modules/lodash.startcase": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "dev": true, "license": "MIT" }, "node_modules/lodash.uniq": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, "node_modules/lodash.upperfirst": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", "dev": true, "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -13120,8 +10342,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -13129,8 +10349,6 @@ }, "node_modules/make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -13145,8 +10363,6 @@ }, "node_modules/make-fetch-happen": { "version": "15.0.5", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", - "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -13168,14 +10384,10 @@ }, "node_modules/mark.js": { "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", "license": "MIT" }, "node_modules/markdown-it": { "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -13191,8 +10403,6 @@ }, "node_modules/markdown-it-anchor": { "version": "8.6.7", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", - "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", "license": "Unlicense", "peerDependencies": { "@types/markdown-it": "*", @@ -13201,8 +10411,6 @@ }, "node_modules/markdown-it-implicit-figures": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/markdown-it-implicit-figures/-/markdown-it-implicit-figures-0.12.0.tgz", - "integrity": "sha512-IeD2V74f3ZBYrZ+bz/9uEGii0S61BYoD2731qsHTgYLlENUrTevlgODScScS1CK44/TV9ddlufGHCYCQueh1rw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13210,8 +10418,6 @@ }, "node_modules/marked": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", - "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -13222,8 +10428,6 @@ }, "node_modules/matcher": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz", - "integrity": "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==", "dev": true, "license": "MIT", "dependencies": { @@ -13238,8 +10442,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -13247,8 +10449,6 @@ }, "node_modules/md5-hex": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", - "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", "dev": true, "license": "MIT", "dependencies": { @@ -13260,8 +10460,6 @@ }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -13281,20 +10479,14 @@ }, "node_modules/mdn-data": { "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/mdurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, "node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -13302,8 +10494,6 @@ }, "node_modules/memoize": { "version": "10.2.0", - "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", - "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", "dev": true, "license": "MIT", "dependencies": { @@ -13318,8 +10508,6 @@ }, "node_modules/meow": { "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, "license": "MIT", "engines": { @@ -13331,8 +10519,6 @@ }, "node_modules/merge-descriptors": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13340,8 +10526,6 @@ }, "node_modules/merge2": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "license": "MIT", "engines": { "node": ">= 8" @@ -13349,8 +10533,6 @@ }, "node_modules/methods": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -13358,15 +10540,11 @@ }, "node_modules/micro-spelling-correcter": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/micro-spelling-correcter/-/micro-spelling-correcter-1.1.1.tgz", - "integrity": "sha512-lkJ3Rj/mtjlRcHk6YyCbvZhyWTOzdBvTHsxMmZSk5jxN1YyVSQ+JETAom55mdzfcyDrY/49Z7UCW760BK30crg==", "dev": true, "license": "CC0-1.0" }, "node_modules/micromark-util-character": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "funding": [ { "type": "GitHub Sponsors", @@ -13385,8 +10563,6 @@ }, "node_modules/micromark-util-encode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "funding": [ { "type": "GitHub Sponsors", @@ -13401,8 +10577,6 @@ }, "node_modules/micromark-util-sanitize-uri": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "funding": [ { "type": "GitHub Sponsors", @@ -13422,8 +10596,6 @@ }, "node_modules/micromark-util-symbol": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "funding": [ { "type": "GitHub Sponsors", @@ -13438,8 +10610,6 @@ }, "node_modules/micromark-util-types": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "funding": [ { "type": "GitHub Sponsors", @@ -13454,8 +10624,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -13467,8 +10635,6 @@ }, "node_modules/micromatch/node_modules/picomatch": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -13479,8 +10645,6 @@ }, "node_modules/mime": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", "bin": { "mime": "cli.js" @@ -13491,8 +10655,6 @@ }, "node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -13500,8 +10662,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -13512,8 +10672,6 @@ }, "node_modules/mime-types/node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -13521,8 +10679,6 @@ }, "node_modules/mimic-function": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", "engines": { @@ -13534,14 +10690,10 @@ }, "node_modules/minimalistic-assert": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "license": "ISC" }, "node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -13555,8 +10707,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13564,8 +10714,6 @@ }, "node_modules/minipass": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -13573,8 +10721,6 @@ }, "node_modules/minipass-collect": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -13585,8 +10731,6 @@ }, "node_modules/minipass-fetch": { "version": "5.0.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", - "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "license": "MIT", "dependencies": { "minipass": "^7.0.3", @@ -13602,8 +10746,6 @@ }, "node_modules/minipass-flush": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", "license": "BlueOak-1.0.0", "dependencies": { "minipass": "^3.0.0" @@ -13614,8 +10756,6 @@ }, "node_modules/minipass-flush/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -13626,14 +10766,10 @@ }, "node_modules/minipass-flush/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/minipass-pipeline": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -13644,8 +10780,6 @@ }, "node_modules/minipass-pipeline/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -13656,14 +10790,10 @@ }, "node_modules/minipass-pipeline/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/minipass-sized": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", - "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", "license": "ISC", "dependencies": { "minipass": "^7.1.2" @@ -13674,14 +10804,10 @@ }, "node_modules/minisearch": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", "license": "MIT" }, "node_modules/minizlib": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -13692,14 +10818,10 @@ }, "node_modules/mitt": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, "node_modules/mkdirp": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -13710,14 +10832,10 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -13734,15 +10852,11 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -13750,15 +10864,11 @@ }, "node_modules/neo-async": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, "license": "MIT" }, "node_modules/node-fetch": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dev": true, "license": "MIT", "dependencies": { @@ -13778,8 +10888,6 @@ }, "node_modules/node-gyp": { "version": "12.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", - "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", "license": "MIT", "dependencies": { "env-paths": "^2.2.0", @@ -13802,8 +10910,6 @@ }, "node_modules/node-gyp-build": { "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", "dev": true, "license": "MIT", "bin": { @@ -13814,8 +10920,6 @@ }, "node_modules/node-gyp/node_modules/nopt": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "dependencies": { "abbrev": "^4.0.0" @@ -13829,8 +10933,6 @@ }, "node_modules/node-preload": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", - "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13842,14 +10944,10 @@ }, "node_modules/node-releases": { "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, "node_modules/node-stream-zip": { "version": "1.15.0", - "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", - "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -13861,8 +10959,6 @@ }, "node_modules/nofilter": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", - "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", "dev": true, "license": "MIT", "engines": { @@ -13871,8 +10967,6 @@ }, "node_modules/nopt": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "dev": true, "license": "ISC", "dependencies": { @@ -13887,8 +10981,6 @@ }, "node_modules/nopt/node_modules/abbrev": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", "dev": true, "license": "ISC", "engines": { @@ -13897,8 +10989,6 @@ }, "node_modules/normalize-package-data": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", - "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^9.0.0", @@ -13911,8 +11001,6 @@ }, "node_modules/npm-bundled": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", - "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", "license": "ISC", "dependencies": { "npm-normalize-package-bin": "^5.0.0" @@ -13923,8 +11011,6 @@ }, "node_modules/npm-install-checks": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", - "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" @@ -13935,15 +11021,11 @@ }, "node_modules/npm-license-corrections": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/npm-license-corrections/-/npm-license-corrections-1.9.0.tgz", - "integrity": "sha512-9Tq6y6zop5lsZy6dInbgrCLnqtuN+3jBc9NCusKjbeQL4LRudDkvmCYyInsDOaKN7GIVbBSvDto5MnEqYXVhxQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/npm-normalize-package-bin": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", - "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -13951,8 +11033,6 @@ }, "node_modules/npm-package-arg": { "version": "13.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", - "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", "license": "ISC", "dependencies": { "hosted-git-info": "^9.0.0", @@ -13966,8 +11046,6 @@ }, "node_modules/npm-packlist": { "version": "10.0.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", - "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", "license": "ISC", "dependencies": { "ignore-walk": "^8.0.0", @@ -13979,8 +11057,6 @@ }, "node_modules/npm-pick-manifest": { "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", - "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", "license": "ISC", "dependencies": { "npm-install-checks": "^8.0.0", @@ -13994,8 +11070,6 @@ }, "node_modules/npm-registry-fetch": { "version": "19.1.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", - "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", "license": "ISC", "dependencies": { "@npmcli/redact": "^4.0.0", @@ -14013,8 +11087,6 @@ }, "node_modules/npm-run-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { @@ -14030,8 +11102,6 @@ }, "node_modules/npm-run-path/node_modules/path-key": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", "engines": { @@ -14043,8 +11113,6 @@ }, "node_modules/nth-check": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -14055,8 +11123,6 @@ }, "node_modules/nyc": { "version": "17.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", - "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", "dev": true, "license": "ISC", "dependencies": { @@ -14097,8 +11163,6 @@ }, "node_modules/nyc/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -14107,8 +11171,6 @@ }, "node_modules/nyc/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -14123,15 +11185,11 @@ }, "node_modules/nyc/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/nyc/node_modules/brace-expansion": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -14141,8 +11199,6 @@ }, "node_modules/nyc/node_modules/cliui": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "dev": true, "license": "ISC", "dependencies": { @@ -14153,23 +11209,16 @@ }, "node_modules/nyc/node_modules/convert-source-map": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true, "license": "MIT" }, "node_modules/nyc/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/nyc/node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -14189,8 +11238,6 @@ }, "node_modules/nyc/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -14199,8 +11246,6 @@ }, "node_modules/nyc/node_modules/istanbul-lib-instrument": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -14216,8 +11261,6 @@ }, "node_modules/nyc/node_modules/make-dir": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "dev": true, "license": "MIT", "dependencies": { @@ -14232,8 +11275,6 @@ }, "node_modules/nyc/node_modules/make-dir/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -14242,8 +11283,6 @@ }, "node_modules/nyc/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -14255,8 +11294,6 @@ }, "node_modules/nyc/node_modules/p-map": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14268,9 +11305,6 @@ }, "node_modules/nyc/node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -14285,15 +11319,11 @@ }, "node_modules/nyc/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, "node_modules/nyc/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -14307,8 +11337,6 @@ }, "node_modules/nyc/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -14320,8 +11348,6 @@ }, "node_modules/nyc/node_modules/wrap-ansi": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "license": "MIT", "dependencies": { @@ -14335,15 +11361,11 @@ }, "node_modules/nyc/node_modules/y18n": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "dev": true, "license": "ISC" }, "node_modules/nyc/node_modules/yargs": { "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "dev": true, "license": "MIT", "dependencies": { @@ -14365,8 +11387,6 @@ }, "node_modules/nyc/node_modules/yargs-parser": { "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "dev": true, "license": "ISC", "dependencies": { @@ -14379,8 +11399,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14388,15 +11406,11 @@ }, "node_modules/object-deep-merge": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", - "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", "dev": true, "license": "MIT" }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -14407,8 +11421,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -14417,8 +11429,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -14438,14 +11448,10 @@ }, "node_modules/obuf": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, "node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -14456,8 +11462,6 @@ }, "node_modules/on-headers": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -14465,8 +11469,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -14475,8 +11477,6 @@ }, "node_modules/oniguruma-to-es": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", "license": "MIT", "dependencies": { "emoji-regex-xs": "^1.0.0", @@ -14486,8 +11486,6 @@ }, "node_modules/open": { "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "license": "MIT", "dependencies": { "default-browser": "^5.2.1", @@ -14504,8 +11502,6 @@ }, "node_modules/open-cli": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/open-cli/-/open-cli-8.0.0.tgz", - "integrity": "sha512-3muD3BbfLyzl+aMVSEfn2FfOqGdPYR0O4KNnxXsLEPE2q9OSjBfJAaB6XKbrUzLgymoSMejvb5jpXJfru/Ko2A==", "dev": true, "license": "MIT", "dependencies": { @@ -14527,8 +11523,6 @@ }, "node_modules/open-cli/node_modules/meow": { "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", "dev": true, "license": "MIT", "engines": { @@ -14540,8 +11534,6 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -14558,8 +11550,6 @@ }, "node_modules/own-keys": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { @@ -14576,8 +11566,6 @@ }, "node_modules/oxc-resolver": { "version": "11.19.1", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", - "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", "dev": true, "license": "MIT", "funding": { @@ -14608,8 +11596,6 @@ }, "node_modules/p-limit": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -14623,8 +11609,6 @@ }, "node_modules/p-locate": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -14635,8 +11619,6 @@ }, "node_modules/p-map": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "license": "MIT", "engines": { "node": ">=18" @@ -14647,8 +11629,6 @@ }, "node_modules/p-try": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", "engines": { "node": ">=6" @@ -14656,8 +11636,6 @@ }, "node_modules/package-config": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/package-config/-/package-config-5.0.0.tgz", - "integrity": "sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==", "dev": true, "license": "MIT", "dependencies": { @@ -14673,8 +11651,6 @@ }, "node_modules/package-hash": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", - "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", "dev": true, "license": "ISC", "dependencies": { @@ -14689,8 +11665,6 @@ }, "node_modules/package-json": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", - "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", "license": "MIT", "dependencies": { "ky": "^1.2.0", @@ -14707,14 +11681,10 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, "node_modules/pacote": { "version": "21.5.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.0.tgz", - "integrity": "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -14744,8 +11714,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -14757,8 +11725,6 @@ }, "node_modules/parent-module/node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -14767,8 +11733,6 @@ }, "node_modules/parse-conflict-json": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-5.0.1.tgz", - "integrity": "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==", "license": "ISC", "dependencies": { "json-parse-even-better-errors": "^5.0.0", @@ -14781,8 +11745,6 @@ }, "node_modules/parse-imports-exports": { "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14791,8 +11753,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -14810,15 +11770,11 @@ }, "node_modules/parse-json/node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, "license": "MIT" }, "node_modules/parse-ms": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, "license": "MIT", "engines": { @@ -14830,15 +11786,11 @@ }, "node_modules/parse-statements": { "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", "dev": true, "license": "MIT" }, "node_modules/parse5": { "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -14849,8 +11801,6 @@ }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "license": "MIT", "dependencies": { "domhandler": "^5.0.3", @@ -14862,8 +11812,6 @@ }, "node_modules/parse5-parser-stream": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", "license": "MIT", "dependencies": { "parse5": "^7.0.0" @@ -14874,8 +11822,6 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -14886,8 +11832,6 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -14895,8 +11839,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", "engines": { "node": ">=8" @@ -14904,8 +11846,6 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { @@ -14914,8 +11854,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -14923,14 +11861,10 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -14945,20 +11879,14 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, "node_modules/path-to-regexp": { "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/path-type": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", "license": "MIT", "engines": { "node": ">=18" @@ -14969,8 +11897,6 @@ }, "node_modules/peek-readable": { "version": "5.4.2", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", - "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", "dev": true, "license": "MIT", "engines": { @@ -14983,20 +11909,14 @@ }, "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -15007,8 +11927,6 @@ }, "node_modules/pkg-dir": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", "dev": true, "license": "MIT", "dependencies": { @@ -15020,8 +11938,6 @@ }, "node_modules/pkg-dir/node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -15037,8 +11953,6 @@ }, "node_modules/pkg-dir/node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -15053,8 +11967,6 @@ }, "node_modules/pkg-dir/node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15069,8 +11981,6 @@ }, "node_modules/pkg-dir/node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -15085,8 +11995,6 @@ }, "node_modules/plur": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz", - "integrity": "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==", "dev": true, "license": "MIT", "dependencies": { @@ -15101,8 +12009,6 @@ }, "node_modules/portscanner": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/portscanner/-/portscanner-2.2.0.tgz", - "integrity": "sha512-IFroCz/59Lqa2uBvzK3bKDbDDIEaAY8XJ1jFxcLWTqosrsc32//P4VuSB2vZXoHiHqOmx8B5L5hnKOxL/7FlPw==", "license": "MIT", "dependencies": { "async": "^2.6.0", @@ -15115,8 +12021,6 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -15125,8 +12029,6 @@ }, "node_modules/postcss": { "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -15153,8 +12055,6 @@ }, "node_modules/postcss-calc": { "version": "10.1.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz", - "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==", "license": "MIT", "dependencies": { "postcss-selector-parser": "^7.0.0", @@ -15169,8 +12069,6 @@ }, "node_modules/postcss-colormin": { "version": "7.0.7", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.7.tgz", - "integrity": "sha512-sBQ628lSj3VQpDquQel8Pen5mmjFPsO4pH9lDLaHB1AVkMRHtkl0pRB5DCWznc9upWsxint/kV+AveSj7W1tew==", "license": "MIT", "dependencies": { "@colordx/core": "^5.0.0", @@ -15187,8 +12085,6 @@ }, "node_modules/postcss-convert-values": { "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.9.tgz", - "integrity": "sha512-l6uATQATZaCa0bckHV+r6dLXfWtUBKXxO3jK+AtxxJJtgMPD+VhhPCCx51I4/5w8U5uHV67g3w7PXj+V3wlMlg==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -15203,8 +12099,6 @@ }, "node_modules/postcss-discard-comments": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.6.tgz", - "integrity": "sha512-Sq+Fzj1Eg5/CPf1ERb0wS1Im5cvE2gDXCE+si4HCn1sf+jpQZxDI4DXEp8t77B/ImzDceWE2ebJQFXdqZ6GRJw==", "license": "MIT", "dependencies": { "postcss-selector-parser": "^7.1.1" @@ -15218,8 +12112,6 @@ }, "node_modules/postcss-discard-duplicates": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.2.tgz", - "integrity": "sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" @@ -15230,8 +12122,6 @@ }, "node_modules/postcss-discard-empty": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.1.tgz", - "integrity": "sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" @@ -15242,8 +12132,6 @@ }, "node_modules/postcss-discard-overridden": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.1.tgz", - "integrity": "sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" @@ -15254,8 +12142,6 @@ }, "node_modules/postcss-merge-longhand": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz", - "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", @@ -15270,8 +12156,6 @@ }, "node_modules/postcss-merge-rules": { "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.8.tgz", - "integrity": "sha512-BOR1iAM8jnr7zoQSlpeBmCsWV5Uudi/+5j7k05D0O/WP3+OFMPD86c1j/20xiuRtyt45bhxw/7hnhZNhW2mNFA==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -15288,8 +12172,6 @@ }, "node_modules/postcss-minify-font-values": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.1.tgz", - "integrity": "sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15303,8 +12185,6 @@ }, "node_modules/postcss-minify-gradients": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.2.tgz", - "integrity": "sha512-fVY3AB8Um7SJR5usHqTY2Ngf9qh8IRN+FFzrBP0ONJy6yYXsP7xyjK2BvSAIrpgs1cST+H91V0TXi3diHLYJtw==", "license": "MIT", "dependencies": { "@colordx/core": "^5.0.0", @@ -15320,8 +12200,6 @@ }, "node_modules/postcss-minify-params": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.6.tgz", - "integrity": "sha512-YOn02gC68JijlaXVuKvFSCvQOhTpblkcfDre2hb/Aaa58r2BIaK4AtE/cyZf2wV7YKAG+UlP9DT+By0ry1E4VQ==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -15337,8 +12215,6 @@ }, "node_modules/postcss-minify-selectors": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.6.tgz", - "integrity": "sha512-lIbC0jy3AAwDxEgciZlBullDiMBeBCT+fz5G8RcA9MWqh/hfUkpOI3vNDUNEZHgokaoiv0juB9Y8fGcON7rU/A==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -15353,8 +12229,6 @@ }, "node_modules/postcss-normalize-charset": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.1.tgz", - "integrity": "sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" @@ -15365,8 +12239,6 @@ }, "node_modules/postcss-normalize-display-values": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.1.tgz", - "integrity": "sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15380,8 +12252,6 @@ }, "node_modules/postcss-normalize-positions": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.1.tgz", - "integrity": "sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15395,8 +12265,6 @@ }, "node_modules/postcss-normalize-repeat-style": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.1.tgz", - "integrity": "sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15410,8 +12278,6 @@ }, "node_modules/postcss-normalize-string": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.1.tgz", - "integrity": "sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15425,8 +12291,6 @@ }, "node_modules/postcss-normalize-timing-functions": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.1.tgz", - "integrity": "sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15440,8 +12304,6 @@ }, "node_modules/postcss-normalize-unicode": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.6.tgz", - "integrity": "sha512-z6bwTV84YW6ZvvNoaNLuzRW4/uWxDKYI1iIDrzk6D2YTL7hICApy+Q1LP6vBEsljX8FM7YSuV9qI79XESd4ddQ==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -15456,8 +12318,6 @@ }, "node_modules/postcss-normalize-url": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.1.tgz", - "integrity": "sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15471,8 +12331,6 @@ }, "node_modules/postcss-normalize-whitespace": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.1.tgz", - "integrity": "sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15486,8 +12344,6 @@ }, "node_modules/postcss-ordered-values": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.2.tgz", - "integrity": "sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==", "license": "MIT", "dependencies": { "cssnano-utils": "^5.0.1", @@ -15502,8 +12358,6 @@ }, "node_modules/postcss-reduce-initial": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.6.tgz", - "integrity": "sha512-G6ZyK68AmrPdMB6wyeA37ejnnRG2S8xinJrZJnOv+IaRKf6koPAVbQsiC7MfkmXaGmF1UO+QCijb27wfpxuRNg==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -15518,8 +12372,6 @@ }, "node_modules/postcss-reduce-transforms": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.1.tgz", - "integrity": "sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15533,8 +12385,6 @@ }, "node_modules/postcss-selector-parser": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -15546,8 +12396,6 @@ }, "node_modules/postcss-svgo": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.1.tgz", - "integrity": "sha512-zU9H9oEDrUFKa0JB7w+IYL7Qs9ey1mZyjhbf0KLxwJDdDRtoPvCmaEfknzqfHj44QS9VD6c5sJnBAVYTLRg/Sg==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", @@ -15562,8 +12410,6 @@ }, "node_modules/postcss-unique-selectors": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.5.tgz", - "integrity": "sha512-3QoYmEt4qg/rUWDn6Tc8+ZVPmbp4G1hXDtCNWDx0st8SjtCbRcxRXDDM1QrEiXGG3A45zscSJFb4QH90LViyxg==", "license": "MIT", "dependencies": { "postcss-selector-parser": "^7.1.1" @@ -15577,14 +12423,10 @@ }, "node_modules/postcss-value-parser": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, "node_modules/preact": { "version": "10.29.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", - "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", "license": "MIT", "funding": { "type": "opencollective", @@ -15593,8 +12435,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -15603,17 +12443,10 @@ }, "node_modules/pretty-data": { "version": "0.40.0", - "resolved": "https://registry.npmjs.org/pretty-data/-/pretty-data-0.40.0.tgz", - "integrity": "sha512-YFLnEdDEDnkt/GEhet5CYZHCvALw6+Elyb/tp8kQG03ZSIuzeaDWpZYndCXwgqu4NAjh1PI534dhDS1mHarRnQ==", - "license": "MIT", - "engines": { - "node": "*" - } + "license": "MIT" }, "node_modules/pretty-hrtime": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -15621,8 +12454,6 @@ }, "node_modules/pretty-ms": { "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15637,8 +12468,6 @@ }, "node_modules/proc-log": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -15646,8 +12475,6 @@ }, "node_modules/process": { "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, "license": "MIT", "engines": { @@ -15656,14 +12483,10 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, "node_modules/process-on-spawn": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", - "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -15675,8 +12498,6 @@ }, "node_modules/proggy": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/proggy/-/proggy-4.0.0.tgz", - "integrity": "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -15684,8 +12505,6 @@ }, "node_modules/promise-all-reject-late": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", - "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", "license": "ISC", "funding": { "url": "https://github.com/sponsors/isaacs" @@ -15693,8 +12512,6 @@ }, "node_modules/promise-call-limit": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", - "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", "license": "ISC", "funding": { "url": "https://github.com/sponsors/isaacs" @@ -15702,15 +12519,11 @@ }, "node_modules/promise-inflight": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", "dev": true, "license": "ISC" }, "node_modules/promise-retry": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, "license": "MIT", "dependencies": { @@ -15723,8 +12536,6 @@ }, "node_modules/property-information": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", "funding": { "type": "github", @@ -15733,14 +12544,10 @@ }, "node_modules/proto-list": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, "node_modules/proxy-addr": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -15752,8 +12559,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -15762,8 +12567,6 @@ }, "node_modules/punycode.js": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", "engines": { "node": ">=6" @@ -15771,8 +12574,6 @@ }, "node_modules/pupa": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", - "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -15786,8 +12587,6 @@ }, "node_modules/qs": { "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -15801,8 +12600,6 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "funding": [ { "type": "github", @@ -15821,8 +12618,6 @@ }, "node_modules/quibble": { "version": "0.9.2", - "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.9.2.tgz", - "integrity": "sha512-BrL7hrZcbyyt5ZDfePkGFDc3m82uUtxCPOnpRUrkOdtBnmV9ldQKxXORkKL8eIzToRNaCpIPyKyfdfq/tBlFAA==", "dev": true, "license": "MIT", "dependencies": { @@ -15835,8 +12630,6 @@ }, "node_modules/random-int": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/random-int/-/random-int-3.1.0.tgz", - "integrity": "sha512-h8CRz8cpvzj0hC/iH/1Gapgcl2TQ6xtnCpyOI5WvWfXf/yrDx2DOU+tD9rX23j36IF11xg1KqB9W11Z18JPMdw==", "license": "MIT", "engines": { "node": ">=12" @@ -15847,8 +12640,6 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -15856,8 +12647,6 @@ }, "node_modules/raw-body": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -15871,8 +12660,6 @@ }, "node_modules/rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -15886,14 +12673,10 @@ }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15901,8 +12684,6 @@ }, "node_modules/read-cmd-shim": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", - "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -15910,8 +12691,6 @@ }, "node_modules/read-package-json-fast": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", - "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", "dev": true, "license": "ISC", "dependencies": { @@ -15924,8 +12703,6 @@ }, "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", - "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", "dev": true, "license": "MIT", "engines": { @@ -15934,8 +12711,6 @@ }, "node_modules/read-package-json-fast/node_modules/npm-normalize-package-bin": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", "dev": true, "license": "ISC", "engines": { @@ -15976,8 +12751,6 @@ }, "node_modules/read-pkg": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", - "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.4", @@ -15995,8 +12768,6 @@ }, "node_modules/read-pkg/node_modules/parse-json": { "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -16012,8 +12783,6 @@ }, "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -16024,8 +12793,6 @@ }, "node_modules/read-pkg/node_modules/type-fest": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -16039,8 +12806,6 @@ }, "node_modules/read-pkg/node_modules/unicorn-magic": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", - "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", "license": "MIT", "engines": { "node": ">=20" @@ -16051,8 +12816,6 @@ }, "node_modules/readable-stream": { "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", "dependencies": { @@ -16068,8 +12831,6 @@ }, "node_modules/readable-web-to-node-stream": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", - "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", "dev": true, "license": "MIT", "dependencies": { @@ -16085,8 +12846,6 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { @@ -16108,8 +12867,6 @@ }, "node_modules/regex": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -16117,8 +12874,6 @@ }, "node_modules/regex-recursion": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -16126,14 +12881,10 @@ }, "node_modules/regex-utilities": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -16153,8 +12904,6 @@ }, "node_modules/registry-auth-token": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", - "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^3.0.2" @@ -16165,8 +12914,6 @@ }, "node_modules/registry-url": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", "license": "MIT", "dependencies": { "rc": "1.2.8" @@ -16180,8 +12927,6 @@ }, "node_modules/release-zalgo": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", - "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", "dev": true, "license": "ISC", "dependencies": { @@ -16243,8 +12988,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", "engines": { @@ -16253,8 +12996,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16262,15 +13003,11 @@ }, "node_modules/require-main-filename": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true, "license": "ISC" }, "node_modules/requizzle": { "version": "0.2.4", - "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", - "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", "license": "MIT", "dependencies": { "lodash": "^4.17.21" @@ -16278,8 +13015,6 @@ }, "node_modules/reserved-identifiers": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", - "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", "dev": true, "license": "MIT", "engines": { @@ -16291,8 +13026,6 @@ }, "node_modules/resolve": { "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -16311,8 +13044,6 @@ }, "node_modules/resolve-cwd": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" @@ -16323,8 +13054,6 @@ }, "node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "license": "MIT", "engines": { "node": ">=8" @@ -16332,8 +13061,6 @@ }, "node_modules/retry": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", "engines": { @@ -16342,8 +13069,6 @@ }, "node_modules/reusify": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -16352,14 +13077,10 @@ }, "node_modules/rfdc": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, "node_modules/rimraf": { "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -16378,8 +13099,6 @@ }, "node_modules/rimraf/node_modules/glob": { "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -16396,8 +13115,6 @@ }, "node_modules/rimraf/node_modules/lru-cache": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -16406,8 +13123,6 @@ }, "node_modules/rimraf/node_modules/path-scurry": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -16423,8 +13138,6 @@ }, "node_modules/rollup": { "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -16467,8 +13180,6 @@ }, "node_modules/router": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -16483,8 +13194,6 @@ }, "node_modules/router/node_modules/path-to-regexp": { "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -16493,8 +13202,6 @@ }, "node_modules/run-applescript": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "license": "MIT", "engines": { "node": ">=18" @@ -16505,8 +13212,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "funding": [ { "type": "github", @@ -16528,8 +13233,6 @@ }, "node_modules/safe-array-concat": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -16548,15 +13251,11 @@ }, "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -16575,8 +13274,6 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { @@ -16592,15 +13289,11 @@ }, "node_modules/safe-push-apply/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -16617,14 +13310,10 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/sax": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -16632,21 +13321,15 @@ }, "node_modules/search-insights": { "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", "license": "MIT", "peer": true }, "node_modules/select-hose": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -16657,8 +13340,6 @@ }, "node_modules/send": { "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -16681,8 +13362,6 @@ }, "node_modules/send/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -16690,14 +13369,10 @@ }, "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/serialize-error": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "dev": true, "license": "MIT", "dependencies": { @@ -16712,8 +13387,6 @@ }, "node_modules/serialize-error/node_modules/type-fest": { "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -16725,8 +13398,6 @@ }, "node_modules/serve-static": { "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", @@ -16740,15 +13411,11 @@ }, "node_modules/set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true, "license": "ISC" }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -16765,8 +13432,6 @@ }, "node_modules/set-function-name": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16781,8 +13446,6 @@ }, "node_modules/set-proto": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { @@ -16796,14 +13459,10 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -16814,8 +13473,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -16823,8 +13480,6 @@ }, "node_modules/shiki": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", "license": "MIT", "dependencies": { "@shikijs/core": "2.5.0", @@ -16839,8 +13494,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16858,8 +13511,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16874,8 +13525,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16892,8 +13541,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16911,8 +13558,6 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" @@ -16923,8 +13568,6 @@ }, "node_modules/sigstore": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", - "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", @@ -16940,8 +13583,6 @@ }, "node_modules/sinon": { "version": "21.0.3", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.3.tgz", - "integrity": "sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -16958,8 +13599,6 @@ }, "node_modules/slash": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "license": "MIT", "engines": { "node": ">=14.16" @@ -16970,8 +13609,6 @@ }, "node_modules/slice-ansi": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16987,8 +13624,6 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -16997,8 +13632,6 @@ }, "node_modules/smol-toml": { "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -17010,8 +13643,6 @@ }, "node_modules/socks": { "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -17024,8 +13655,6 @@ }, "node_modules/socks-proxy-agent": { "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -17038,8 +13667,6 @@ }, "node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -17047,8 +13674,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -17056,8 +13681,6 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -17066,8 +13689,6 @@ }, "node_modules/space-separated-tokens": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", "funding": { "type": "github", @@ -17076,8 +13697,6 @@ }, "node_modules/spawn-wrap": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", "dev": true, "license": "ISC", "dependencies": { @@ -17094,15 +13713,11 @@ }, "node_modules/spawn-wrap/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/spawn-wrap/node_modules/brace-expansion": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -17112,8 +13727,6 @@ }, "node_modules/spawn-wrap/node_modules/foreground-child": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", "dev": true, "license": "ISC", "dependencies": { @@ -17126,9 +13739,6 @@ }, "node_modules/spawn-wrap/node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -17148,15 +13758,11 @@ }, "node_modules/spawn-wrap/node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/spawn-wrap/node_modules/make-dir": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "dev": true, "license": "MIT", "dependencies": { @@ -17171,8 +13777,6 @@ }, "node_modules/spawn-wrap/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -17184,9 +13788,6 @@ }, "node_modules/spawn-wrap/node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -17201,8 +13802,6 @@ }, "node_modules/spawn-wrap/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -17211,15 +13810,11 @@ }, "node_modules/spawn-wrap/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, "node_modules/spawn-wrap/node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -17234,8 +13829,6 @@ }, "node_modules/spdx-compare": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/spdx-compare/-/spdx-compare-1.0.0.tgz", - "integrity": "sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==", "dev": true, "license": "MIT", "dependencies": { @@ -17246,8 +13839,6 @@ }, "node_modules/spdx-compare/node_modules/spdx-expression-parse": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -17257,8 +13848,6 @@ }, "node_modules/spdx-correct": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -17267,8 +13856,6 @@ }, "node_modules/spdx-correct/node_modules/spdx-expression-parse": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -17277,14 +13864,10 @@ }, "node_modules/spdx-exceptions": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -17293,8 +13876,6 @@ }, "node_modules/spdx-expression-validate": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-validate/-/spdx-expression-validate-2.0.0.tgz", - "integrity": "sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg==", "dev": true, "license": "(MIT AND CC-BY-3.0)", "dependencies": { @@ -17303,8 +13884,6 @@ }, "node_modules/spdx-expression-validate/node_modules/spdx-expression-parse": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -17314,28 +13893,20 @@ }, "node_modules/spdx-license-ids": { "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "license": "CC0-1.0" }, "node_modules/spdx-osi": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdx-osi/-/spdx-osi-3.0.0.tgz", - "integrity": "sha512-7DZMaD/rNHWGf82qWOazBsLXQsaLsoJb9RRjhEUQr5o86kw3A1ErGzSdvaXl+KalZyKkkU5T2a5NjCCutAKQSw==", "dev": true, "license": "CC0-1.0" }, "node_modules/spdx-ranges": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/spdx-ranges/-/spdx-ranges-2.1.1.tgz", - "integrity": "sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==", "dev": true, "license": "(MIT AND CC-BY-3.0)" }, "node_modules/spdx-whitelisted": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/spdx-whitelisted/-/spdx-whitelisted-1.0.0.tgz", - "integrity": "sha512-X4FOpUCvZuo42MdB1zAZ/wdX4N0lLcWDozf2KYFVDgtLv8Lx+f31LOYLP2/FcwTzsPi64bS/VwKqklI4RBletg==", "dev": true, "license": "MIT", "dependencies": { @@ -17345,8 +13916,6 @@ }, "node_modules/spdy": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", "license": "MIT", "dependencies": { "debug": "^4.1.0", @@ -17361,8 +13930,6 @@ }, "node_modules/spdy-transport": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", "license": "MIT", "dependencies": { "debug": "^4.1.0", @@ -17375,8 +13942,6 @@ }, "node_modules/spdy-transport/node_modules/readable-stream": { "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -17389,8 +13954,6 @@ }, "node_modules/speakingurl": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -17398,15 +13961,11 @@ }, "node_modules/sprintf-js": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/ssri": { "version": "13.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", - "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -17417,8 +13976,6 @@ }, "node_modules/stack-utils": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17430,8 +13987,6 @@ }, "node_modules/stack-utils/node_modules/escape-string-regexp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, "license": "MIT", "engines": { @@ -17440,8 +13995,6 @@ }, "node_modules/statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -17449,8 +14002,6 @@ }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17463,8 +14014,6 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -17472,8 +14021,6 @@ }, "node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -17490,8 +14037,6 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -17504,8 +14049,6 @@ }, "node_modules/string-width-cjs/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -17513,14 +14056,10 @@ }, "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -17528,8 +14067,6 @@ }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -17540,8 +14077,6 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { @@ -17562,8 +14097,6 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17581,8 +14114,6 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { @@ -17599,8 +14130,6 @@ }, "node_modules/stringify-entities": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", @@ -17613,8 +14142,6 @@ }, "node_modules/stringify-object-es5": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/stringify-object-es5/-/stringify-object-es5-2.5.0.tgz", - "integrity": "sha512-vE7Xdx9ylG4JI16zy7/ObKUB+MtxuMcWlj/WHHr3+yAlQoN6sst2stU9E+2Qs3OrlJw/Pf3loWxL1GauEHf6MA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -17627,8 +14154,6 @@ }, "node_modules/stringify-object-es5/node_modules/is-plain-obj": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "dev": true, "license": "MIT", "engines": { @@ -17637,8 +14162,6 @@ }, "node_modules/strip-ansi": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -17653,8 +14176,6 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -17665,8 +14186,6 @@ }, "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -17674,8 +14193,6 @@ }, "node_modules/strip-bom": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "license": "MIT", "engines": { @@ -17684,8 +14201,6 @@ }, "node_modules/strip-final-newline": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { @@ -17697,8 +14212,6 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "license": "MIT", "engines": { "node": ">=8" @@ -17709,8 +14222,6 @@ }, "node_modules/strtok3": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.1.1.tgz", - "integrity": "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==", "dev": true, "license": "MIT", "dependencies": { @@ -17727,8 +14238,6 @@ }, "node_modules/stubborn-fs": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", "license": "MIT", "dependencies": { "stubborn-utils": "^1.0.1" @@ -17736,14 +14245,10 @@ }, "node_modules/stubborn-utils": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, "node_modules/stylehacks": { "version": "7.0.8", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.8.tgz", - "integrity": "sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1", @@ -17758,8 +14263,6 @@ }, "node_modules/superagent": { "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17779,8 +14282,6 @@ }, "node_modules/superagent/node_modules/mime": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "license": "MIT", "bin": { @@ -17792,8 +14293,6 @@ }, "node_modules/superjson": { "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", "license": "MIT", "dependencies": { "copy-anything": "^4" @@ -17804,8 +14303,6 @@ }, "node_modules/supertap": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz", - "integrity": "sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==", "dev": true, "license": "MIT", "dependencies": { @@ -17820,8 +14317,6 @@ }, "node_modules/supertap/node_modules/argparse": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", "dependencies": { @@ -17830,8 +14325,6 @@ }, "node_modules/supertap/node_modules/js-yaml": { "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -17844,8 +14337,6 @@ }, "node_modules/supertest": { "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", "dependencies": { @@ -17859,8 +14350,6 @@ }, "node_modules/supertest/node_modules/cookie-signature": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, "license": "MIT", "engines": { @@ -17869,8 +14358,6 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -17882,8 +14369,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -17894,8 +14379,6 @@ }, "node_modules/svgo": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", "license": "MIT", "dependencies": { "commander": "^11.1.0", @@ -17919,8 +14402,6 @@ }, "node_modules/svgo/node_modules/commander": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "license": "MIT", "engines": { "node": ">=16" @@ -17928,14 +14409,10 @@ }, "node_modules/tabbable": { "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tagged-tag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", "engines": { "node": ">=20" @@ -17946,14 +14423,10 @@ }, "node_modules/tailwindcss": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "license": "MIT", "engines": { "node": ">=6" @@ -17965,8 +14438,6 @@ }, "node_modules/tar": { "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -17981,8 +14452,6 @@ }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -17990,8 +14459,6 @@ }, "node_modules/temp-dir": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", "dev": true, "license": "MIT", "engines": { @@ -18000,8 +14467,6 @@ }, "node_modules/tempy": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", - "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18019,8 +14484,6 @@ }, "node_modules/tempy/node_modules/is-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, "license": "MIT", "engines": { @@ -18032,8 +14495,6 @@ }, "node_modules/tempy/node_modules/type-fest": { "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -18045,8 +14506,6 @@ }, "node_modules/terser": { "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -18063,14 +14522,10 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, "node_modules/test-exclude": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, "license": "ISC", "dependencies": { @@ -18084,15 +14539,11 @@ }, "node_modules/test-exclude/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -18102,9 +14553,6 @@ }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -18124,8 +14572,6 @@ }, "node_modules/test-exclude/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -18137,8 +14583,6 @@ }, "node_modules/testdouble": { "version": "3.20.2", - "resolved": "https://registry.npmjs.org/testdouble/-/testdouble-3.20.2.tgz", - "integrity": "sha512-790e9vJKdfddWNOaxW1/V9FcMk48cPEl3eJSj2i8Hh1fX89qArEJ6cp3DBnaECpGXc3xKJVWbc1jeNlWYWgiMg==", "dev": true, "license": "MIT", "dependencies": { @@ -18153,15 +14597,11 @@ }, "node_modules/theredoc": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/theredoc/-/theredoc-1.0.0.tgz", - "integrity": "sha512-KU3SA3TjRRM932jpNfD3u4Ec3bSvedyo5ITPI7zgWYnKep7BwQQaxlhI9qbO+lKJoRnoAbEVfMcAHRuKVYikDA==", "dev": true, "license": "MIT" }, "node_modules/time-zone": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz", - "integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==", "dev": true, "license": "MIT", "engines": { @@ -18170,8 +14610,6 @@ }, "node_modules/tinyexec": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -18180,8 +14618,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -18196,8 +14632,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -18208,8 +14642,6 @@ }, "node_modules/to-valid-identifier": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", - "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", "dev": true, "license": "MIT", "dependencies": { @@ -18225,8 +14657,6 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", "engines": { "node": ">=0.6" @@ -18234,8 +14664,6 @@ }, "node_modules/token-types": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", - "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", "dev": true, "license": "MIT", "dependencies": { @@ -18252,15 +14680,11 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true, "license": "MIT" }, "node_modules/traverse": { "version": "0.6.11", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz", - "integrity": "sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==", "dev": true, "license": "MIT", "dependencies": { @@ -18277,8 +14701,6 @@ }, "node_modules/treeverse": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", - "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -18286,8 +14708,6 @@ }, "node_modules/trim-lines": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "license": "MIT", "funding": { "type": "github", @@ -18296,15 +14716,12 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, "node_modules/tuf-js": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", - "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "license": "MIT", "dependencies": { "@tufjs/models": "4.1.0", @@ -18317,8 +14734,6 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -18330,8 +14745,6 @@ }, "node_modules/type-detect": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, "license": "MIT", "engines": { @@ -18340,8 +14753,6 @@ }, "node_modules/type-fest": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -18350,8 +14761,6 @@ }, "node_modules/type-is": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -18364,8 +14773,6 @@ }, "node_modules/type-is/node_modules/mime-types": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -18380,8 +14787,6 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { @@ -18395,8 +14800,6 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -18415,8 +14818,6 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18437,8 +14838,6 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -18458,8 +14857,6 @@ }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", "dev": true, "license": "MIT", "dependencies": { @@ -18468,8 +14865,6 @@ }, "node_modules/typedarray.prototype.slice": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", - "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", "dev": true, "license": "MIT", "dependencies": { @@ -18491,8 +14886,6 @@ }, "node_modules/typescript": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "devOptional": true, "license": "Apache-2.0", "peer": true, @@ -18506,14 +14899,10 @@ }, "node_modules/uc.micro": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, "node_modules/uglify-js": { "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "dev": true, "license": "BSD-2-Clause", "optional": true, @@ -18526,8 +14915,6 @@ }, "node_modules/unbash": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", - "integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==", "dev": true, "license": "ISC", "engines": { @@ -18536,8 +14923,6 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -18555,14 +14940,10 @@ }, "node_modules/underscore": { "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/undici": { "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -18570,14 +14951,10 @@ }, "node_modules/undici-types": { "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unicorn-magic": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "license": "MIT", "engines": { "node": ">=18" @@ -18588,8 +14965,6 @@ }, "node_modules/unique-filename": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", "dev": true, "license": "ISC", "dependencies": { @@ -18601,8 +14976,6 @@ }, "node_modules/unique-slug": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", "dev": true, "license": "ISC", "dependencies": { @@ -18614,8 +14987,6 @@ }, "node_modules/unique-string": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18630,8 +15001,6 @@ }, "node_modules/unist-util-is": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -18643,8 +15012,6 @@ }, "node_modules/unist-util-position": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -18656,8 +15023,6 @@ }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -18669,8 +15034,6 @@ }, "node_modules/unist-util-visit": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18684,8 +15047,6 @@ }, "node_modules/unist-util-visit-parents": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18698,8 +15059,6 @@ }, "node_modules/unpipe": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -18707,8 +15066,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -18737,8 +15094,6 @@ }, "node_modules/update-notifier": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", - "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", "license": "BSD-2-Clause", "dependencies": { "boxen": "^8.0.1", @@ -18761,8 +15116,6 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -18771,14 +15124,10 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -18786,8 +15135,6 @@ }, "node_modules/uuid": { "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, "license": "MIT", "bin": { @@ -18796,8 +15143,6 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -18806,8 +15151,6 @@ }, "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -18816,8 +15159,6 @@ }, "node_modules/validate-npm-package-name": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -18825,8 +15166,6 @@ }, "node_modules/vary": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -18834,8 +15173,6 @@ }, "node_modules/vfile": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18848,8 +15185,6 @@ }, "node_modules/vfile-message": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18862,8 +15197,6 @@ }, "node_modules/vite": { "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -18921,8 +15254,6 @@ }, "node_modules/vitepress": { "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", "license": "MIT", "dependencies": { "@docsearch/css": "3.8.2", @@ -18962,8 +15293,6 @@ }, "node_modules/vue": { "version": "3.5.32", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", - "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.32", @@ -18983,8 +15312,6 @@ }, "node_modules/walk-up-path": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", - "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", "license": "ISC", "engines": { "node": "20 || >=22" @@ -18992,8 +15319,6 @@ }, "node_modules/wbuf": { "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", "license": "MIT", "dependencies": { "minimalistic-assert": "^1.0.0" @@ -19001,15 +15326,11 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/well-known-symbols": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz", - "integrity": "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==", "dev": true, "license": "ISC", "engines": { @@ -19018,9 +15339,6 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -19031,8 +15349,6 @@ }, "node_modules/whatwg-encoding/node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -19043,8 +15359,6 @@ }, "node_modules/whatwg-mimetype": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "license": "MIT", "engines": { "node": ">=18" @@ -19052,8 +15366,6 @@ }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dev": true, "license": "MIT", "dependencies": { @@ -19063,14 +15375,10 @@ }, "node_modules/when-exit": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", "license": "MIT" }, "node_modules/which": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -19084,8 +15392,6 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -19104,8 +15410,6 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -19132,15 +15436,11 @@ }, "node_modules/which-builtin-type/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -19158,15 +15458,11 @@ }, "node_modules/which-module": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "dev": true, "license": "ISC" }, "node_modules/which-typed-array": { "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -19187,8 +15483,6 @@ }, "node_modules/widest-line": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -19202,8 +15496,6 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -19212,21 +15504,15 @@ }, "node_modules/wordwrap": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true, "license": "MIT" }, "node_modules/workerpool": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.1.tgz", - "integrity": "sha512-NAnKwZJxWlj/U1cp6ZkEtPE+GQY1S6KtOS3AlCiPfPFLxV3m64giSp7g2LsNJxzYCocDT7TSl+7T0sgrDp3KoQ==", "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -19243,8 +15529,6 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -19260,8 +15544,6 @@ }, "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -19269,8 +15551,6 @@ }, "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -19284,14 +15564,10 @@ }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -19299,8 +15575,6 @@ }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19313,8 +15587,6 @@ }, "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -19325,14 +15597,10 @@ }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -19348,15 +15616,11 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", - "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", "dev": true, "license": "ISC", "dependencies": { @@ -19369,8 +15633,6 @@ }, "node_modules/wsl-utils": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", "license": "MIT", "dependencies": { "is-wsl": "^3.1.0" @@ -19384,8 +15646,6 @@ }, "node_modules/xdg-basedir": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", "license": "MIT", "engines": { "node": ">=12" @@ -19396,8 +15656,6 @@ }, "node_modules/xml2js": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", "license": "MIT", "dependencies": { "sax": ">=0.6.0", @@ -19409,8 +15667,6 @@ }, "node_modules/xmlbuilder": { "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "license": "MIT", "engines": { "node": ">=4.0" @@ -19418,14 +15674,10 @@ }, "node_modules/xmlcreate": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", - "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", "license": "Apache-2.0" }, "node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "license": "ISC", "engines": { "node": ">=10" @@ -19433,15 +15685,11 @@ }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, "node_modules/yaml": { "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -19456,14 +15704,10 @@ }, "node_modules/yaml-ast-parser": { "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", "license": "Apache-2.0" }, "node_modules/yargs": { "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { @@ -19481,8 +15725,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { @@ -19491,8 +15733,6 @@ }, "node_modules/yargs/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -19501,15 +15741,11 @@ }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -19518,8 +15754,6 @@ }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -19533,8 +15767,6 @@ }, "node_modules/yargs/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -19546,14 +15778,10 @@ }, "node_modules/yesno": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/yesno/-/yesno-0.4.0.tgz", - "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==", "license": "BSD" }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -19565,8 +15793,6 @@ }, "node_modules/yoctocolors": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "dev": true, "license": "MIT", "engines": { @@ -19578,8 +15804,6 @@ }, "node_modules/zod": { "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", "funding": { @@ -19588,8 +15812,6 @@ }, "node_modules/zwitch": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", "license": "MIT", "funding": { "type": "github", @@ -19678,8 +15900,6 @@ }, "packages/cli/node_modules/cliui": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -19692,8 +15912,6 @@ }, "packages/cli/node_modules/wrap-ansi": { "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -19709,8 +15927,6 @@ }, "packages/cli/node_modules/yargs": { "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -19726,8 +15942,6 @@ }, "packages/cli/node_modules/yargs-parser": { "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -19765,8 +15979,6 @@ }, "packages/fs/node_modules/globby": { "version": "15.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-15.0.0.tgz", - "integrity": "sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -19785,8 +15997,6 @@ }, "packages/fs/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" @@ -19826,6 +16036,7 @@ "@ui5/logger": "^5.0.0-alpha.4", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", + "cacache": "^20.0.3", "chalk": "^5.6.2", "escape-string-regexp": "^5.0.0", "globby": "^14.1.0", @@ -19840,6 +16051,7 @@ "read-pkg": "^10.0.0", "resolve": "^1.22.10", "semver": "^7.7.2", + "ssri": "^13.0.0", "xml2js": "^0.6.2", "yesno": "^0.4.0" }, @@ -19873,8 +16085,6 @@ }, "packages/project/node_modules/istanbul-lib-instrument": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/project/package.json b/packages/project/package.json index a4c6a9f61cc..d45a980dc57 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -61,6 +61,7 @@ "@ui5/logger": "^5.0.0-alpha.4", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", + "cacache": "^20.0.3", "chalk": "^5.6.2", "escape-string-regexp": "^5.0.0", "globby": "^14.1.0", @@ -75,6 +76,7 @@ "read-pkg": "^10.0.0", "resolve": "^1.22.10", "semver": "^7.7.2", + "ssri": "^13.0.0", "xml2js": "^0.6.2", "yesno": "^0.4.0" }, From cad641ba553bb298a41bac81a00408d640c2e6e0 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 28 Nov 2025 10:12:39 +0100 Subject: [PATCH 009/223] refactor(project): Add cache manager --- .../project/lib/build/cache/CacheManager.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/project/lib/build/cache/CacheManager.js diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js new file mode 100644 index 00000000000..138b0d8d373 --- /dev/null +++ b/packages/project/lib/build/cache/CacheManager.js @@ -0,0 +1,28 @@ +import cacache from "cacache"; + +export class CacheManager { + constructor(cacheDir) { + this._cacheDir = cacheDir; + } + + async get(cacheKey) { + try { + const result = await cacache.get(this._cacheDir, cacheKey); + return JSON.parse(result.data.toString("utf-8")); + } catch (err) { + if (err.code === "ENOENT" || err.code === "EINTEGRITY") { + // Cache miss + return null; + } + throw err; + } + } + + async put(cacheKey, data) { + await cacache.put(this._cacheDir, cacheKey, data); + } + + async putStream(cacheKey, stream) { + await cacache.put.stream(this._cacheDir, cacheKey, stream); + } +} From 8bc18af9135ac2d7dd0b2c4c23d571dec3357f66 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 27 Nov 2025 10:40:01 +0100 Subject: [PATCH 010/223] refactor(fs): Refactor Resource internals * Improve handling for concurrent resource access and modifications, especially when buffering streams. * Deprecate getStatInfo in favor of dedicated getSize, isDirectory, getLastModified methods. * Deprecate synchronous getStream in favor of getStreamAsync and modifyStream, allowing for atomic modification of resource content * Generate Resource hash using ssri --- package-lock.json | 33 +- packages/fs/lib/Resource.js | 695 +++++++--- packages/fs/lib/ResourceFacade.js | 70 +- packages/fs/package.json | 4 +- packages/fs/test/lib/Resource.js | 1220 ++++++++++++++++- packages/fs/test/lib/ResourceFacade.js | 3 +- .../fs/test/lib/adapters/FileSystem_write.js | 6 +- 7 files changed, 1783 insertions(+), 248 deletions(-) diff --git a/package-lock.json b/package-lock.json index d51c18651ba..776e14ba0e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4876,6 +4876,15 @@ "node": ">= 0.4" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/async-sema": { "version": "3.1.1", "dev": true, @@ -12719,8 +12728,6 @@ }, "node_modules/read-package-up": { "version": "12.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", - "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", "license": "MIT", "dependencies": { "find-up-simple": "^1.0.1", @@ -12736,8 +12743,6 @@ }, "node_modules/read-package-up/node_modules/type-fest": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -12938,8 +12943,6 @@ }, "node_modules/replacestream": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/replacestream/-/replacestream-4.0.3.tgz", - "integrity": "sha512-AC0FiLS352pBBiZhd4VXB1Ab/lh0lEgpP+GGvZqbQh8a5cmXVoTe5EX/YeTFArnp4SRGTHh1qCHu9lGs1qG8sA==", "license": "BSD-3-Clause", "dependencies": { "escape-string-regexp": "^1.0.3", @@ -12949,8 +12952,6 @@ }, "node_modules/replacestream/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -12958,8 +12959,6 @@ }, "node_modules/replacestream/node_modules/readable-stream": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -12973,14 +12972,10 @@ }, "node_modules/replacestream/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, "node_modules/replacestream/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -14716,9 +14711,9 @@ }, "node_modules/tslib": { "version": "2.8.1", - "dev": true, - "license": "0BSD", - "optional": true + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -15953,6 +15948,7 @@ "license": "Apache-2.0", "dependencies": { "@ui5/logger": "^5.0.0-alpha.4", + "async-mutex": "^0.5.0", "clone": "^2.1.2", "escape-string-regexp": "^5.0.0", "globby": "^15.0.0", @@ -15960,7 +15956,8 @@ "micromatch": "^4.0.8", "minimatch": "^10.2.2", "pretty-hrtime": "^1.0.3", - "random-int": "^3.1.0" + "random-int": "^3.1.0", + "ssri": "^13.0.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index 1cefc2ce490..19c937ba4b4 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -1,10 +1,28 @@ -import stream from "node:stream"; -import crypto from "node:crypto"; +import {Readable} from "node:stream"; +import {buffer as streamToBuffer} from "node:stream/consumers"; +import ssri from "ssri"; import clone from "clone"; import posixPath from "node:path/posix"; +import {setTimeout} from "node:timers/promises"; +import {withTimeout, Mutex} from "async-mutex"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("fs:Resource"); +let deprecatedGetStreamCalled = false; +let deprecatedGetStatInfoCalled = false; const ALLOWED_SOURCE_METADATA_KEYS = ["adapter", "fsPath", "contentModified"]; +const CONTENT_TYPES = { + BUFFER: "buffer", + STREAM: "stream", + FACTORY: "factory", + DRAINED_STREAM: "drainedStream", + IN_TRANSFORMATION: "inTransformation", +}; + +const SSRI_OPTIONS = {algorithms: ["sha256"]}; + /** * Resource. UI5 CLI specific representation of a file's content and metadata * @@ -13,26 +31,37 @@ const ALLOWED_SOURCE_METADATA_KEYS = ["adapter", "fsPath", "contentModified"]; * @alias @ui5/fs/Resource */ class Resource { - #project; - #buffer; - #buffering; - #collections; - #contentDrained; - #createStream; #name; #path; + #project; #sourceMetadata; + + /* Resource Content */ + #content; + #createBufferFactory; + #createStreamFactory; + #contentType; + + /* Content Metadata */ + #byteSize; + #lastModified; #statInfo; - #stream; - #streamDrained; - #isModified; + #isDirectory; + + /* States */ + #isModified = false; + // Mutex to prevent access/modification while content is being transformed. 100 ms timeout + #contentMutex = withTimeout(new Mutex(), 100, new Error("Timeout waiting for resource content access")); + + // Tracing + #collections = []; /** - * Function for dynamic creation of content streams + * Factory function to dynamic (and potentially repeated) creation of readable streams of the resource's content * * @public * @callback @ui5/fs/Resource~createStream - * @returns {stream.Readable} A readable stream of a resources content + * @returns {stream.Readable} A readable stream of the resource's content */ /** @@ -48,6 +77,9 @@ class Resource { * (cannot be used in conjunction with parameters buffer, stream or createStream) * @param {Stream} [parameters.stream] Readable stream of the content of this resource * (cannot be used in conjunction with parameters buffer, string or createStream) + * @param {@ui5/fs/Resource~createBuffer} [parameters.createBuffer] Function callback that returns a promise + * resolving with a Buffer of the content of this resource (cannot be used in conjunction with + * parameters buffer, string or stream). Must be used in conjunction with parameters createStream. * @param {@ui5/fs/Resource~createStream} [parameters.createStream] Function callback that returns a readable * stream of the content of this resource (cannot be used in conjunction with parameters buffer, * string or stream). @@ -56,20 +88,32 @@ class Resource { * @param {object} [parameters.sourceMetadata] Source metadata for UI5 CLI internal use. * Some information may be set by an adapter to store information for later retrieval. Also keeps track of whether * a resource content has been modified since it has been read from a source + * @param {boolean} [parameters.isDirectory] Flag whether the resource represents a directory + * @param {number} [parameters.byteSize] Size of the resource content in bytes + * @param {number} [parameters.lastModified] Last modified timestamp (in milliseconds since UNIX epoch) */ - constructor({path, statInfo, buffer, string, createStream, stream, project, sourceMetadata}) { + constructor({ + path, statInfo, buffer, createBuffer, string, createStream, stream, project, sourceMetadata, + isDirectory, byteSize, lastModified, + }) { if (!path) { throw new Error("Unable to create Resource: Missing parameter 'path'"); } + if (createBuffer && !createStream) { + // If createBuffer is provided, createStream must be provided as well + throw new Error("Unable to create Resource: Parameter 'createStream' must be provided when " + + "parameter 'createBuffer' is used"); + } if (buffer && createStream || buffer && string || string && createStream || buffer && stream || string && stream || createStream && stream) { - throw new Error("Unable to create Resource: Please set only one content parameter. " + + throw new Error("Unable to create Resource: Multiple content parameters provided. " + + "Please provide only one of the following parameters: " + "'buffer', 'string', 'stream' or 'createStream'"); } if (sourceMetadata) { if (typeof sourceMetadata !== "object") { - throw new Error(`Parameter 'sourceMetadata' must be of type "object"`); + throw new Error(`Unable to create Resource: Parameter 'sourceMetadata' must be of type "object"`); } /* eslint-disable-next-line guard-for-in */ @@ -95,33 +139,82 @@ class Resource { // Since the sourceMetadata object is inherited to clones, it is the only correct indicator this.#sourceMetadata.contentModified ??= false; - this.#isModified = false; - this.#project = project; if (createStream) { - this.#createStream = createStream; - } else if (stream) { - this.#stream = stream; + // We store both factories individually + // This allows to create either a stream or a buffer on demand + // Note that it's possible and acceptable if only one factory is provided + if (createBuffer) { + if (typeof createBuffer !== "function") { + throw new Error("Unable to create Resource: Parameter 'createBuffer' must be a function"); + } + this.#createBufferFactory = createBuffer; + } + // createStream is always provided if a factory is used + if (typeof createStream !== "function") { + throw new Error("Unable to create Resource: Parameter 'createStream' must be a function"); + } + this.#createStreamFactory = createStream; + this.#contentType = CONTENT_TYPES.FACTORY; + } if (stream) { + if (typeof stream !== "object" || typeof stream.pipe !== "function") { + throw new Error("Unable to create Resource: Parameter 'stream' must be a readable stream"); + } + this.#content = stream; + this.#contentType = CONTENT_TYPES.STREAM; } else if (buffer) { - // Use private setter, not to accidentally set any modified flags - this.#setBuffer(buffer); - } else if (typeof string === "string" || string instanceof String) { - // Use private setter, not to accidentally set any modified flags - this.#setBuffer(Buffer.from(string, "utf8")); + if (!Buffer.isBuffer(buffer)) { + throw new Error("Unable to create Resource: Parameter 'buffer' must be of type Buffer"); + } + this.#content = buffer; + this.#contentType = CONTENT_TYPES.BUFFER; + } else if (string !== undefined) { + if (typeof string !== "string" && !(string instanceof String)) { + throw new Error("Unable to create Resource: Parameter 'string' must be of type string"); + } + this.#content = Buffer.from(string, "utf8"); // Assuming utf8 encoding + this.#contentType = CONTENT_TYPES.BUFFER; + } + + if (isDirectory !== undefined) { + this.#isDirectory = !!isDirectory; + } + if (byteSize !== undefined) { + if (typeof byteSize !== "number" || byteSize < 0) { + throw new Error("Unable to create Resource: Parameter 'byteSize' must be a positive number"); + } + this.#byteSize = byteSize; + } + if (lastModified !== undefined) { + if (typeof lastModified !== "number" || lastModified < 0) { + throw new Error("Unable to create Resource: Parameter 'lastModified' must be a positive number"); + } + this.#lastModified = lastModified; } if (statInfo) { + this.#isDirectory ??= statInfo.isDirectory(); + if (!this.#isDirectory && statInfo.isFile && !statInfo.isFile()) { + throw new Error("Unable to create Resource: statInfo must represent either a file or a directory"); + } + this.#byteSize ??= statInfo.size; + this.#lastModified ??= statInfo.mtimeMs; + + // Create legacy statInfo object this.#statInfo = parseStat(statInfo); } else { - if (createStream || stream) { - throw new Error("Unable to create Resource: Please provide statInfo for stream content"); - } - this.#statInfo = createStat(this.#buffer.byteLength); + // if (this.#byteSize === undefined && this.#contentType) { + // if (this.#contentType !== CONTENT_TYPES.BUFFER) { + // throw new Error("Unable to create Resource: byteSize or statInfo must be provided when resource " + + // "content is stream- or factory-based"); + // } + // this.#byteSize ??= this.#content.byteLength; + // } + + // Create legacy statInfo object + this.#statInfo = createStat(this.#byteSize, this.#isDirectory, this.#lastModified); } - - // Tracing: - this.#collections = []; } /** @@ -131,20 +224,56 @@ class Resource { * @returns {Promise} Promise resolving with a buffer of the resource content. */ async getBuffer() { - if (this.#contentDrained) { - throw new Error(`Content of Resource ${this.#path} has been drained. ` + - "This might be caused by requesting resource content after a content stream has been " + - "requested and no new content (e.g. a new stream) has been set."); - } - if (this.#buffer) { - return this.#buffer; - } else if (this.#createStream || this.#stream) { - return this.#getBufferFromStream(); - } else { + // First wait for new content if the current content is flagged as drained + if (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + await this.#waitForNewContent(); + } + + // Then make sure no other operation is currently modifying the content (such as a concurrent getBuffer call + // that might be transforming the content right now) + if (this.#contentMutex.isLocked()) { + await this.#contentMutex.waitForUnlock(); + } + + switch (this.#contentType) { + case CONTENT_TYPES.FACTORY: + if (this.#createBufferFactory) { + // Prefer buffer factory if available + return await this.#getBufferFromFactory(this.#createBufferFactory); + } + // Fallback to stream factory + return this.#getBufferFromStream(this.#createStreamFactory()); + case CONTENT_TYPES.STREAM: + return this.#getBufferFromStream(this.#content); + case CONTENT_TYPES.BUFFER: + return this.#content; + case CONTENT_TYPES.DRAINED_STREAM: + // waitForNewContent call above should prevent this from ever happening + throw new Error(`Unexpected error: Content of Resource ${this.#path} is flagged as drained.`); + case CONTENT_TYPES.IN_TRANSFORMATION: + // contentMutex.waitForUnlock call above should prevent this from ever happening + throw new Error(`Unexpected error: Content of Resource ${this.#path} is currently being transformed`); + default: throw new Error(`Resource ${this.#path} has no content`); } } + async #getBufferFromFactory(factoryFn) { + const release = await this.#contentMutex.acquire(); + try { + this.#contentType = CONTENT_TYPES.IN_TRANSFORMATION; + const buffer = await factoryFn(); + if (!Buffer.isBuffer(buffer)) { + throw new Error(`Buffer factory of Resource ${this.#path} did not return a Buffer instance`); + } + this.#content = buffer; + this.#contentType = CONTENT_TYPES.BUFFER; + return buffer; + } finally { + release(); + } + } + /** * Sets a Buffer as content. * @@ -152,21 +281,12 @@ class Resource { * @param {Buffer} buffer Buffer instance */ setBuffer(buffer) { - this.#sourceMetadata.contentModified = true; - this.#isModified = true; - this.#updateStatInfo(buffer); - this.#setBuffer(buffer); - } - - #setBuffer(buffer) { - this.#createStream = null; - // if (this.#stream) { // TODO this may cause strange issues - // this.#stream.destroy(); - // } - this.#stream = null; - this.#buffer = buffer; - this.#contentDrained = false; - this.#streamDrained = false; + if (this.#contentMutex.isLocked()) { + throw new Error(`Unable to set buffer: Content of Resource ${this.#path} is currently being transformed`); + } + this.#content = buffer; + this.#contentType = CONTENT_TYPES.BUFFER; + this.#contendModified(); } /** @@ -175,13 +295,9 @@ class Resource { * @public * @returns {Promise} Promise resolving with the resource content. */ - getString() { - if (this.#contentDrained) { - return Promise.reject(new Error(`Content of Resource ${this.#path} has been drained. ` + - "This might be caused by requesting resource content after a content stream has been " + - "requested and no new content (e.g. a new stream) has been set.")); - } - return this.getBuffer().then((buffer) => buffer.toString()); + async getString() { + const buff = await this.getBuffer(); + return buff.toString("utf8"); } /** @@ -199,29 +315,127 @@ class Resource { * * Repetitive calls of this function are only possible if new content has been set in the meantime (through * [setStream]{@link @ui5/fs/Resource#setStream}, [setBuffer]{@link @ui5/fs/Resource#setBuffer} - * or [setString]{@link @ui5/fs/Resource#setString}). This - * is to prevent consumers from accessing drained streams. + * or [setString]{@link @ui5/fs/Resource#setString}). + * This is to prevent subsequent consumers from accessing drained streams. + * + * This method is deprecated. Please use the asynchronous version + * [getStreamAsync]{@link @ui5/fs/Resource#getStreamAsync} instead. + * + * For atomic operations, consider using [modifyStream]{@link @ui5/fs/Resource#modifyStream}. * * @public + * @deprecated Use asynchronous Resource.getStreamAsync() instead * @returns {stream.Readable} Readable stream for the resource content. */ getStream() { - if (this.#contentDrained) { - throw new Error(`Content of Resource ${this.#path} has been drained. ` + - "This might be caused by requesting resource content after a content stream has been " + - "requested and no new content (e.g. a new stream) has been set."); - } - let contentStream; - if (this.#buffer) { - const bufferStream = new stream.PassThrough(); - bufferStream.end(this.#buffer); - contentStream = bufferStream; - } else if (this.#createStream || this.#stream) { - contentStream = this.#getStream(); - } - if (!contentStream) { + if (!deprecatedGetStreamCalled) { + log.verbose(`[DEPRECATION] Synchronous Resource.getStream() is deprecated and will be removed ` + + `in future versions. Please use asynchronous Resource.getStreamAsync() instead.`); + deprecatedGetStreamCalled = true; + } + + // First check for drained content + if (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + throw new Error(`Content of Resource ${this.#path} is currently flagged as drained. ` + + `Consider using Resource.getStreamAsync() to wait for new content.`); + } + + // Make sure no other operation is currently modifying the content + if (this.#contentMutex.isLocked()) { + throw new Error(`Content of Resource ${this.#path} is currently being transformed. ` + + `Consider using Resource.getStreamAsync() to wait for the transformation to finish.`); + } + + return this.#getStream(); + } + + /** + * Gets a readable stream for the resource content. + * + * Repetitive calls of this function are only possible if new content has been set in the meantime (through + * [setStream]{@link @ui5/fs/Resource#setStream}, [setBuffer]{@link @ui5/fs/Resource#setBuffer} + * or [setString]{@link @ui5/fs/Resource#setString}). + * This is to prevent subsequent consumers from accessing drained streams. + * + * For atomic operations, consider using [modifyStream]{@link @ui5/fs/Resource#modifyStream}. + * + * @public + * @returns {Promise} Promise resolving with a readable stream for the resource content. + */ + async getStreamAsync() { + // First wait for new content if the current content is flagged as drained + if (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + await this.#waitForNewContent(); + } + + // Then make sure no other operation is currently modifying the content + if (this.#contentMutex.isLocked()) { + await this.#contentMutex.waitForUnlock(); + } + + return this.#getStream(); + } + + /** + * Modifies the resource content by applying the given callback function. + * The callback function receives a readable stream of the current content + * and must return either a Buffer or a readable stream with the new content. + * The resource content is locked during the modification to prevent concurrent access. + * + * @param {function(stream.Readable): (Buffer|stream.Readable|Promise)} callback + */ + async modifyStream(callback) { + // First wait for new content if the current content is flagged as drained + if (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + await this.#waitForNewContent(); + } + // Then make sure no other operation is currently modifying the content and then lock it + const release = await this.#contentMutex.acquire(); + const newContent = await callback(this.#getStream()); + + // New content is either buffer or stream + if (Buffer.isBuffer(newContent)) { + this.#content = newContent; + this.#contentType = CONTENT_TYPES.BUFFER; + } else if (typeof newContent === "object" && typeof newContent.pipe === "function") { + this.#content = newContent; + this.#contentType = CONTENT_TYPES.STREAM; + } else { + throw new Error("Unable to set new content: Content must be either a Buffer or a Readable Stream"); + } + this.#contendModified(); + release(); + } + + /** + * Returns the content as stream. + * + * @private + * @returns {stream.Readable} Readable stream + */ + #getStream() { + let stream; + switch (this.#contentType) { + case CONTENT_TYPES.BUFFER: + stream = Readable.from(this.#content); + break; + case CONTENT_TYPES.FACTORY: + // Prefer stream factory (which must always be set if content type is FACTORY) + stream = this.#createStreamFactory(); + break; + case CONTENT_TYPES.STREAM: + stream = this.#content; + break; + case CONTENT_TYPES.DRAINED_STREAM: + // This case is unexpected as callers should already handle this content type (by waiting for it to change) + throw new Error(`Unexpected error: Content of Resource ${this.#path} is flagged as drained.`); + case CONTENT_TYPES.IN_TRANSFORMATION: + // This case is unexpected as callers should already handle this content type (by waiting for it to change) + throw new Error(`Unexpected error: Content of Resource ${this.#path} is currently being transformed`); + default: throw new Error(`Resource ${this.#path} has no content`); } + // If a stream instance is being returned, it will typically get drained be the consumer. // In that case, further content access will result in a "Content stream has been drained" error. // However, depending on the execution environment, a resources content stream might have been @@ -230,8 +444,56 @@ class Resource { // To prevent unexpected "Content stream has been drained" errors caused by changing environments, we flag // the resource content as "drained" every time a stream is requested. Even if actually a buffer or // createStream callback is being used. - this.#contentDrained = true; - return contentStream; + this.#contentType = CONTENT_TYPES.DRAINED_STREAM; + return stream; + } + + /** + * Converts the buffer into a stream. + * + * @private + * @param {stream.Readable} stream Readable stream + * @returns {Promise} Promise resolving with buffer. + */ + async #getBufferFromStream(stream) { + const release = await this.#contentMutex.acquire(); + try { + this.#contentType = CONTENT_TYPES.IN_TRANSFORMATION; + if (this.hasSize()) { + // If size is known. preallocate buffer for improved performance + try { + const size = await this.getSize(); + const buffer = Buffer.allocUnsafe(size); + let offset = 0; + for await (const chunk of stream) { + const len = chunk.length; + if (offset + len > size) { + throw new Error(`Stream exceeded expected size: ${size}, got at least ${offset + len}`); + } + chunk.copy(buffer, offset); + offset += len; + } + if (offset !== size) { + throw new Error(`Stream ended early: expected ${size} bytes, got ${offset}`); + } + this.#content = buffer; + } catch (err) { + // Ensure the stream is cleaned up on error + if (!stream.destroyed) { + stream.destroy(err); + } + throw err; + } + } else { + // Is size is unknown, simply use utility consumer from Node.js webstreams + // See https://nodejs.org/api/webstreams.html#utility-consumers + this.#content = await streamToBuffer(stream); + } + this.#contentType = CONTENT_TYPES.BUFFER; + } finally { + release(); + } + return this.#content; } /** @@ -242,37 +504,96 @@ class Resource { callback for dynamic creation of a readable stream */ setStream(stream) { - this.#isModified = true; - this.#sourceMetadata.contentModified = true; - - this.#buffer = null; - // if (this.#stream) { // TODO this may cause strange issues - // this.#stream.destroy(); - // } + if (this.#contentMutex.isLocked()) { + throw new Error(`Unable to set stream: Content of Resource ${this.#path} is currently being transformed`); + } if (typeof stream === "function") { - this.#createStream = stream; - this.#stream = null; + this.#content = undefined; + this.#createStreamFactory = stream; + this.#contentType = CONTENT_TYPES.FACTORY; } else { - this.#stream = stream; - this.#createStream = null; + this.#content = stream; + this.#contentType = CONTENT_TYPES.STREAM; } - this.#contentDrained = false; - this.#streamDrained = false; + this.#contendModified(); } async getHash() { - if (this.#statInfo.isDirectory()) { + if (this.isDirectory()) { + throw new Error(`Unable to calculate hash for directory resource: ${this.#path}`); + } + + // First wait for new content if the current content is flagged as drained + if (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + await this.#waitForNewContent(); + } + + // Then make sure no other operation is currently modifying the content + if (this.#contentMutex.isLocked()) { + await this.#contentMutex.waitForUnlock(); + } + + switch (this.#contentType) { + case CONTENT_TYPES.BUFFER: + return ssri.fromData(this.#content, SSRI_OPTIONS).toString(); + case CONTENT_TYPES.FACTORY: + return (await ssri.fromStream(this.#createStreamFactory(), SSRI_OPTIONS)).toString(); + case CONTENT_TYPES.STREAM: + // To be discussed: Should we read the stream into a buffer here (using #getBufferFromStream) to avoid + // draining it? + return (await ssri.fromStream(this.#getStream(), SSRI_OPTIONS)).toString(); + case CONTENT_TYPES.DRAINED_STREAM: + throw new Error(`Unexpected error: Content of Resource ${this.#path} is flagged as drained.`); + case CONTENT_TYPES.IN_TRANSFORMATION: + throw new Error(`Unexpected error: Content of Resource ${this.#path} is currently being transformed`); + default: + throw new Error(`Resource ${this.#path} has no content`); + } + } + + #contendModified() { + this.#sourceMetadata.contentModified = true; + this.#isModified = true; + + this.#byteSize = undefined; + this.#lastModified = new Date().getTime(); // TODO: Always update or keep initial value (= fs stat)? + + if (this.#contentType === CONTENT_TYPES.BUFFER) { + this.#byteSize = this.#content.byteLength; + this.#updateStatInfo(this.#byteSize); + } else { + this.#byteSize = undefined; + // Stat-info can't be updated based on streams or factory functions + } + } + + /** + * In case the resource content is flagged as drained stream, wait for new content to be set. + * Either resolves once the content type is no longer DRAINED_STREAM, or rejects with a timeout error. + */ + async #waitForNewContent() { + if (this.#contentType !== CONTENT_TYPES.DRAINED_STREAM) { return; } - const buffer = await this.getBuffer(); - return crypto.createHash("md5").update(buffer).digest("hex"); + // Stream might currently be processed by another consumer. Try again after a short wait, hoping the + // other consumer has processing it and has set new content + let timeoutCounter = 0; + log.verbose(`Content of Resource ${this.#path} is flagged as drained, waiting for new content...`); + while (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + timeoutCounter++; + await setTimeout(1); + if (timeoutCounter > 100) { // 100 ms timeout + throw new Error(`Timeout waiting for content of Resource ${this.#path} to become available.`); + } + } + // New content is now available } - #updateStatInfo(buffer) { + #updateStatInfo(byteSize) { const now = new Date(); this.#statInfo.mtimeMs = now.getTime(); this.#statInfo.mtime = now; - this.#statInfo.size = buffer.byteLength; + this.#statInfo.size = byteSize; } /** @@ -285,6 +606,12 @@ class Resource { return this.#path; } + /** + * Gets the virtual resources path + * + * @public + * @returns {string} (Virtual) path of the resource + */ getOriginalPath() { return this.#path; } @@ -321,31 +648,71 @@ class Resource { * [fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats} instance. * * @public + * @deprecated Use dedicated APIs like Resource.getSize(), .isDirectory(), .getLastModified() instead * @returns {fs.Stats|object} Instance of [fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats} * or similar object */ getStatInfo() { + if (!deprecatedGetStatInfoCalled) { + log.verbose(`[DEPRECATION] Resource.getStatInfo() is deprecated and will be removed in future versions. ` + + `Please switch to dedicated APIs like Resource.getSize() instead.`); + deprecatedGetStatInfoCalled = true; + } return this.#statInfo; } - getLastModified() { + /** + * Checks whether the resource represents a directory. + * + * @public + * @returns {boolean} True if resource is a directory + */ + isDirectory() { + return this.#isDirectory; + } + /** + * Gets the last modified timestamp of the resource. + * + * @public + * @returns {number} Last modified timestamp (in milliseconds since UNIX epoch) + */ + getLastModified() { + return this.#lastModified; } /** - * Size in bytes allocated by the underlying buffer. + * Resource content size in bytes. * + * @public * @see {TypedArray#byteLength} - * @returns {Promise} size in bytes, 0 if there is no content yet + * @returns {Promise} size in bytes, 0 if the resource has no content */ async getSize() { - return this.#statInfo.size; - // // if resource does not have any content it should have 0 bytes - // if (!this.#buffer && !this.#createStream && !this.#stream) { - // return 0; - // } - // const buffer = await this.getBuffer(); - // return buffer.byteLength; + if (this.#byteSize !== undefined) { + return this.#byteSize; + } + if (this.#contentType === undefined) { + return 0; + } + const buffer = await this.getBuffer(); + this.#byteSize = buffer.byteLength; + return this.#byteSize; + } + + /** + * Checks whether the resource size can be determined without reading the entire content. + * E.g. for buffer-based content or if the size has been provided when the resource was created. + * + * @public + * @returns {boolean} True if size can be determined statically + */ + hasSize() { + return ( + this.#contentType === undefined || // No content => size is 0 + this.#byteSize !== undefined || // Size has been determined already + this.#contentType === CONTENT_TYPES.BUFFER // Buffer content => size can be determined + ); } /** @@ -369,20 +736,40 @@ class Resource { } async #getCloneOptions() { + // First wait for new content if the current content is flagged as drained + if (this.#contentType === CONTENT_TYPES.DRAINED_STREAM) { + await this.#waitForNewContent(); + } + + // Then make sure no other operation is currently modifying the content + if (this.#contentMutex.isLocked()) { + await this.#contentMutex.waitForUnlock(); + } + const options = { path: this.#path, statInfo: this.#statInfo, // Will be cloned in constructor + isDirectory: this.#isDirectory, + byteSize: this.#byteSize, + lastModified: this.#lastModified, sourceMetadata: clone(this.#sourceMetadata) }; - if (this.#stream) { - options.buffer = await this.#getBufferFromStream(); - } else if (this.#createStream) { - options.createStream = this.#createStream; - } else if (this.#buffer) { - options.buffer = this.#buffer; + switch (this.#contentType) { + case CONTENT_TYPES.STREAM: + // When cloning resource we have to read the stream into memory + options.buffer = await this.#getBufferFromStream(this.#content); + break; + case CONTENT_TYPES.BUFFER: + options.buffer = this.#content; + break; + case CONTENT_TYPES.FACTORY: + if (this.#createBufferFactory) { + options.createBuffer = this.#createBufferFactory; + } + options.createStream = this.#createStreamFactory; + break; } - return options; } @@ -463,51 +850,6 @@ class Resource { getSourceMetadata() { return this.#sourceMetadata; } - - /** - * Returns the content as stream. - * - * @private - * @returns {stream.Readable} Readable stream - */ - #getStream() { - if (this.#streamDrained) { - throw new Error(`Content stream of Resource ${this.#path} is flagged as drained.`); - } - if (this.#createStream) { - return this.#createStream(); - } - this.#streamDrained = true; - return this.#stream; - } - - /** - * Converts the buffer into a stream. - * - * @private - * @returns {Promise} Promise resolving with buffer. - */ - #getBufferFromStream() { - if (this.#buffering) { // Prevent simultaneous buffering, causing unexpected access to drained stream - return this.#buffering; - } - return this.#buffering = new Promise((resolve, reject) => { - const contentStream = this.#getStream(); - const buffers = []; - contentStream.on("data", (data) => { - buffers.push(data); - }); - contentStream.on("error", (err) => { - reject(err); - }); - contentStream.on("end", () => { - const buffer = Buffer.concat(buffers); - this.#setBuffer(buffer); - this.#buffering = null; - resolve(buffer); - }); - }); - } } const fnTrue = function() { @@ -525,13 +867,13 @@ const fnFalse = function() { */ function parseStat(statInfo) { return { - isFile: statInfo.isFile.bind(statInfo), - isDirectory: statInfo.isDirectory.bind(statInfo), - isBlockDevice: statInfo.isBlockDevice.bind(statInfo), - isCharacterDevice: statInfo.isCharacterDevice.bind(statInfo), - isSymbolicLink: statInfo.isSymbolicLink.bind(statInfo), - isFIFO: statInfo.isFIFO.bind(statInfo), - isSocket: statInfo.isSocket.bind(statInfo), + isFile: statInfo.isFile?.bind(statInfo), + isDirectory: statInfo.isDirectory?.bind(statInfo), + isBlockDevice: statInfo.isBlockDevice?.bind(statInfo), + isCharacterDevice: statInfo.isCharacterDevice?.bind(statInfo), + isSymbolicLink: statInfo.isSymbolicLink?.bind(statInfo), + isFIFO: statInfo.isFIFO?.bind(statInfo), + isSocket: statInfo.isSocket?.bind(statInfo), ino: statInfo.ino, size: statInfo.size, atimeMs: statInfo.atimeMs, @@ -545,24 +887,25 @@ function parseStat(statInfo) { }; } -function createStat(size) { +function createStat(size, isDirectory = false, lastModified) { const now = new Date(); + const mtime = lastModified === undefined ? now : new Date(lastModified); return { - isFile: fnTrue, - isDirectory: fnFalse, + isFile: isDirectory ? fnFalse : fnTrue, + isDirectory: isDirectory ? fnTrue : fnFalse, isBlockDevice: fnFalse, isCharacterDevice: fnFalse, isSymbolicLink: fnFalse, isFIFO: fnFalse, isSocket: fnFalse, ino: 0, - size, + size, // Might be undefined atimeMs: now.getTime(), - mtimeMs: now.getTime(), + mtimeMs: mtime.getTime(), ctimeMs: now.getTime(), birthtimeMs: now.getTime(), atime: now, - mtime: now, + mtime, ctime: now, birthtime: now, }; diff --git a/packages/fs/lib/ResourceFacade.js b/packages/fs/lib/ResourceFacade.js index 9604b56acd1..d9f8f41b5b1 100644 --- a/packages/fs/lib/ResourceFacade.js +++ b/packages/fs/lib/ResourceFacade.js @@ -46,7 +46,7 @@ class ResourceFacade { } /** - * Gets the resources path + * Gets the path original resource's path * * @public * @returns {string} (Virtual) path of the resource @@ -139,16 +139,46 @@ class ResourceFacade { * * Repetitive calls of this function are only possible if new content has been set in the meantime (through * [setStream]{@link @ui5/fs/Resource#setStream}, [setBuffer]{@link @ui5/fs/Resource#setBuffer} - * or [setString]{@link @ui5/fs/Resource#setString}). This - * is to prevent consumers from accessing drained streams. + * or [setString]{@link @ui5/fs/Resource#setString}). + * This is to prevent subsequent consumers from accessing drained streams. * * @public + * @deprecated Use asynchronous Resource.getStreamAsync() instead * @returns {stream.Readable} Readable stream for the resource content. */ getStream() { return this.#resource.getStream(); } + /** + * Gets a readable stream for the resource content. + * + * Repetitive calls of this function are only possible if new content has been set in the meantime (through + * [setStream]{@link @ui5/fs/Resource#setStream}, [setBuffer]{@link @ui5/fs/Resource#setBuffer} + * or [setString]{@link @ui5/fs/Resource#setString}). + * This is to prevent subsequent consumers from accessing drained streams. + * + * For atomic operations, please use [modifyStream]{@link @ui5/fs/Resource#modifyStream} + * + * @public + * @returns {Promise} Promise resolving with a readable stream for the resource content. + */ + async getStreamAsync() { + return this.#resource.getStreamAsync(); + } + + /** + * Modifies the resource content by applying the given callback function. + * The callback function receives a readable stream of the current content + * and must return either a Buffer or a readable stream with the new content. + * The resource content is locked during the modification to prevent concurrent access. + * + * @param {function(stream.Readable): (Buffer|stream.Readable|Promise)} callback + */ + async modifyStream(callback) { + return this.#resource.modifyStream(callback); + } + /** * Sets a readable stream as content. * @@ -171,6 +201,7 @@ class ResourceFacade { * [fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats} instance. * * @public + * @deprecated Use dedicated APIs like Resource.getSize(), .isDirectory(), .getLastModified() instead * @returns {fs.Stats|object} Instance of [fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats} * or similar object */ @@ -178,16 +209,47 @@ class ResourceFacade { return this.#resource.getStatInfo(); } + /** + * Checks whether the resource represents a directory. + * + * @public + * @returns {boolean} True if resource is a directory + */ + isDirectory() { + return this.#resource.isDirectory(); + } + + /** + * Gets the last modified timestamp of the resource. + * + * @public + * @returns {number} Last modified timestamp (in milliseconds since UNIX epoch) + */ + getLastModified() { + return this.#resource.getLastModified(); + } + /** * Size in bytes allocated by the underlying buffer. * * @see {TypedArray#byteLength} - * @returns {Promise} size in bytes, 0 if there is no content yet + * @returns {Promise} size in bytes, 0 if the resource has no content */ async getSize() { return this.#resource.getSize(); } + /** + * Checks whether the resource size can be determined without reading the entire content. + * E.g. for buffer-based content or if the size has been provided when the resource was created. + * + * @public + * @returns {boolean} True if size can be determined statically + */ + hasSize() { + return this.#resource.hasSize(); + } + /** * Adds a resource collection name that was involved in locating this resource. * diff --git a/packages/fs/package.json b/packages/fs/package.json index 16a0a16e6f3..b763af8bbcb 100644 --- a/packages/fs/package.json +++ b/packages/fs/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@ui5/logger": "^5.0.0-alpha.4", + "async-mutex": "^0.5.0", "clone": "^2.1.2", "escape-string-regexp": "^5.0.0", "globby": "^15.0.0", @@ -63,7 +64,8 @@ "micromatch": "^4.0.8", "minimatch": "^10.2.2", "pretty-hrtime": "^1.0.3", - "random-int": "^3.1.0" + "random-int": "^3.1.0", + "ssri": "^13.0.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/fs/test/lib/Resource.js b/packages/fs/test/lib/Resource.js index aca04728746..97f5d95cb44 100644 --- a/packages/fs/test/lib/Resource.js +++ b/packages/fs/test/lib/Resource.js @@ -1,18 +1,21 @@ import test from "ava"; +import sinon from "sinon"; import {Stream, Transform} from "node:stream"; -import {promises as fs, createReadStream} from "node:fs"; +import {statSync, createReadStream} from "node:fs"; +import {stat, readFile} from "node:fs/promises"; import path from "node:path"; import Resource from "../../lib/Resource.js"; function createBasicResource() { const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); + const statInfo = statSync(fsPath); const resource = new Resource({ path: "/app/index.html", createStream: function() { return createReadStream(fsPath); }, project: {}, - statInfo: {}, + statInfo: statInfo, fsPath }); return resource; @@ -39,6 +42,10 @@ const readStream = (readableStream) => { }); }; +test.afterEach.always((t) => { + sinon.restore(); +}); + test("Resource: constructor with missing path parameter", (t) => { t.throws(() => { new Resource({}); @@ -85,12 +92,152 @@ test("Resource: constructor with duplicated content parameter", (t) => { new Resource(resourceParams); }, { instanceOf: Error, - message: "Unable to create Resource: Please set only one content parameter. " + - "'buffer', 'string', 'stream' or 'createStream'" + message: "Unable to create Resource: Multiple content parameters provided. " + + "Please provide only one of the following parameters: 'buffer', 'string', 'stream' or 'createStream'" }, "Threw with expected error message"); }); }); +test("Resource: constructor with createBuffer factory must provide createStream", (t) => { + t.throws(() => { + new Resource({ + path: "/my/path", + createBuffer: () => Buffer.from("Content"), + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'createStream' must be provided when " + + "parameter 'createBuffer' is used" + }); +}); + +test("Resource: constructor with invalid createBuffer parameter", (t) => { + t.throws(() => { + new Resource({ + path: "/my/path", + createBuffer: "not a function", + createStream: () => { + return new Stream.Readable(); + } + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'createBuffer' must be a function" + }); +}); + +test("Resource: constructor with invalid content parameters", (t) => { + t.throws(() => { + new Resource({ + path: "/my/path", + createStream: "not a function" + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'createStream' must be a function" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + stream: "not a stream" + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'stream' must be a readable stream" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + buffer: "not a buffer" + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'buffer' must be of type Buffer" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + string: 123 + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'string' must be of type string" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + buffer: Buffer.from("Content"), + byteSize: -1 + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'byteSize' must be a positive number" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + buffer: Buffer.from("Content"), + byteSize: "not a number" + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'byteSize' must be a positive number" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + buffer: Buffer.from("Content"), + lastModified: -1 + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'lastModified' must be a positive number" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + buffer: Buffer.from("Content"), + lastModified: "not a number" + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: Parameter 'lastModified' must be a positive number" + }); + + const invalidStatInfo = { + isDirectory: () => false, + isFile: () => false, + size: 100, + mtimeMs: Date.now() + }; + t.throws(() => { + new Resource({ + path: "/my/path", + statInfo: invalidStatInfo + }); + }, { + instanceOf: Error, + message: "Unable to create Resource: statInfo must represent either a file or a directory" + }); + + t.throws(() => { + new Resource({ + path: "/my/path", + sourceMetadata: "invalid value" + }); + }, { + instanceOf: Error, + message: `Unable to create Resource: Parameter 'sourceMetadata' must be of type "object"` + }); +}); + test("Resource: From buffer", async (t) => { const resource = new Resource({ path: "/my/path", @@ -126,6 +273,24 @@ test("Resource: From createStream", async (t) => { const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); const resource = new Resource({ path: "/my/path", + byteSize: 91, + createStream: () => { + return createReadStream(fsPath); + } + }); + t.is(await resource.getSize(), 91, "Content is set"); + t.false(resource.isModified(), "Content of new resource is not modified"); + t.false(resource.getSourceMetadata().contentModified, "Content of new resource is not modified"); +}); + +test("Resource: From createBuffer", async (t) => { + const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); + const resource = new Resource({ + path: "/my/path", + byteSize: 91, + createBuffer: async () => { + return Buffer.from(await readFile(fsPath)); + }, createStream: () => { return createReadStream(fsPath); } @@ -150,6 +315,7 @@ test("Resource: Source metadata", async (t) => { t.is(resource.getSourceMetadata().adapter, "My Adapter", "Correct source metadata 'adapter' value"); t.is(resource.getSourceMetadata().fsPath, "/some/path", "Correct source metadata 'fsPath' value"); }); + test("Resource: Source metadata with modified content", async (t) => { const resource = new Resource({ path: "/my/path", @@ -307,6 +473,31 @@ test("Resource: getStream throwing an error", (t) => { }); }); +test("Resource: getStream call while resource is being transformed", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + }) + }); + + const p1 = resource.getBuffer(); // Trigger async transformation of stream to buffer + t.throws(() => { + resource.getStream(); // Synchronous getStream can't wait for transformation to finish + }, { + message: /Content of Resource \/my\/path\/to\/resource is currently being transformed. Consider using Resource.getStreamAsync\(\) to wait for the transformation to finish./ + }); + await p1; // Wait for initial transformation to finish + + t.false(resource.isModified(), "Resource has not been modified"); + + const value = await resource.getString(); + t.is(value, "Stream content", "Initial content still set"); +}); + test("Resource: setString", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", @@ -315,11 +506,13 @@ test("Resource: setString", async (t) => { t.is(resource.getSourceMetadata().contentModified, false, "sourceMetadata modified flag set correctly"); t.false(resource.isModified(), "Resource is not modified"); + t.falsy(resource.getLastModified(), "lastModified is not set"); resource.setString("Content"); t.is(resource.getSourceMetadata().contentModified, true, "sourceMetadata modified flag updated correctly"); t.true(resource.isModified(), "Resource is modified"); + t.truthy(resource.getLastModified(), "lastModified should be updated"); const value = await resource.getString(); t.is(value, "Content", "String set"); @@ -333,20 +526,48 @@ test("Resource: setBuffer", async (t) => { t.is(resource.getSourceMetadata().contentModified, false, "sourceMetadata modified flag set correctly"); t.false(resource.isModified(), "Resource is not modified"); + t.falsy(resource.getLastModified(), "lastModified is not set"); resource.setBuffer(Buffer.from("Content")); t.is(resource.getSourceMetadata().contentModified, true, "sourceMetadata modified flag updated correctly"); t.true(resource.isModified(), "Resource is modified"); + t.truthy(resource.getLastModified(), "lastModified should be updated"); const value = await resource.getString(); t.is(value, "Content", "String set"); }); +test("Resource: setBuffer call while resource is being transformed", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + }) + }); + + const p1 = resource.getBuffer(); // Trigger async transformation of stream to buffer + t.throws(() => { + resource.setBuffer(Buffer.from("Content")); // Set new buffer while transformation is still ongoing + }, { + message: `Unable to set buffer: Content of Resource /my/path/to/resource is currently being transformed` + }); + await p1; // Wait for initial transformation to finish + + t.false(resource.isModified(), "Resource has not been modified"); + + const value = await resource.getString(); + t.is(value, "Stream content", "Initial content still set"); +}); + test("Resource: size modification", async (t) => { const resource = new Resource({ path: "/my/path/to/resource" }); + t.true(resource.hasSize(), "resource without content has size"); t.is(await resource.getSize(), 0, "initial size without content"); // string @@ -361,9 +582,11 @@ test("Resource: size modification", async (t) => { // buffer resource.setBuffer(Buffer.from("Super")); + t.true(resource.hasSize(), "has size"); t.is(await resource.getSize(), 5, "size after manually setting the string"); const clonedResource1 = await resource.clone(); + t.true(clonedResource1.hasSize(), "has size after cloning"); t.is(await clonedResource1.getSize(), 5, "size after cloning the resource"); // buffer with alloc @@ -378,6 +601,7 @@ test("Resource: size modification", async (t) => { }).getSize(), 1234, "buffer with alloc when passing buffer to constructor"); const clonedResource2 = await resource.clone(); + t.true(clonedResource2.hasSize(), "buffer with alloc after clone has size"); t.is(await clonedResource2.getSize(), 1234, "buffer with alloc after clone"); // stream @@ -392,9 +616,11 @@ test("Resource: size modification", async (t) => { stream.push(null); streamResource.setStream(stream); + t.false(streamResource.hasSize(), "size not yet known for streamResource"); // stream is read and stored in buffer // test parallel size retrieval + await streamResource.getBuffer(); const [size1, size2] = await Promise.all([streamResource.getSize(), streamResource.getSize()]); t.is(size1, 23, "size for streamResource, parallel 1"); t.is(size2, 23, "size for streamResource, parallel 2"); @@ -409,6 +635,7 @@ test("Resource: setStream (Stream)", async (t) => { t.is(resource.getSourceMetadata().contentModified, false, "sourceMetadata modified flag set correctly"); t.false(resource.isModified(), "Resource is not modified"); + t.falsy(resource.getLastModified(), "lastModified is not set"); const stream = new Stream.Readable(); stream._read = function() {}; @@ -421,6 +648,7 @@ test("Resource: setStream (Stream)", async (t) => { t.is(resource.getSourceMetadata().contentModified, true, "sourceMetadata modified flag updated correctly"); t.true(resource.isModified(), "Resource is modified"); + t.truthy(resource.getLastModified(), "lastModified should be updated"); const value = await resource.getString(); t.is(value, "I am a readable stream!", "Stream set correctly"); @@ -434,6 +662,7 @@ test("Resource: setStream (Create stream callback)", async (t) => { t.is(resource.getSourceMetadata().contentModified, false, "sourceMetadata modified flag set correctly"); t.false(resource.isModified(), "Resource is not modified"); + t.falsy(resource.getLastModified(), "lastModified is not set"); resource.setStream(() => { const stream = new Stream.Readable(); @@ -447,11 +676,38 @@ test("Resource: setStream (Create stream callback)", async (t) => { t.is(resource.getSourceMetadata().contentModified, true, "sourceMetadata modified flag updated correctly"); t.true(resource.isModified(), "Resource is modified"); + t.truthy(resource.getLastModified(), "lastModified should be updated"); const value = await resource.getString(); t.is(value, "I am a readable stream!", "Stream set correctly"); }); +test("Resource: setStream call while resource is being transformed", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + }) + }); + + const p1 = resource.getBuffer(); // Trigger async transformation of stream to buffer + t.throws(() => { + resource.setStream(new Stream.Readable()); // Set new stream while transformation is still ongoing + }, { + message: `Unable to set stream: Content of Resource /my/path/to/resource is currently being transformed` + }); + await p1; // Wait for initial transformation to finish + + t.false(resource.isModified(), "Resource has not been modified"); + + const value = await resource.getString(); + t.is(value, "Stream content", "Initial content still set"); +}); + + test("Resource: clone resource with buffer", async (t) => { t.plan(2); @@ -487,6 +743,89 @@ test("Resource: clone resource with stream", async (t) => { t.is(clonedResourceContent, "Content", "Cloned resource has correct content string"); }); +test("Resource: clone resource with createBuffer factory", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + createStream: () => { + const stream = new Stream.Readable(); + stream._read = function() {}; + stream.push("Stream Content"); + stream.push(null); + return stream; + }, + createBuffer: async () => { + return Buffer.from("Buffer Content"); + } + + }); + + const clonedResource = await resource.clone(); + + const clonedResourceContent = await clonedResource.getString(); + t.is(clonedResourceContent, "Buffer Content", "Cloned resource has correct content string"); +}); + +test("Resource: clone resource with createStream factory", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + createStream: () => { + const stream = new Stream.Readable(); + stream._read = function() {}; + stream.push("Stream Content"); + stream.push(null); + return stream; + }, + }); + + const clonedResource = await resource.clone(); + + const clonedResourceContent = await clonedResource.getString(); + t.is(clonedResourceContent, "Stream Content", "Cloned resource has correct content string"); +}); + +test("Resource: clone resource with stream during transformation to buffer", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource" + }); + const stream = new Stream.Readable(); + stream._read = function() {}; + stream.push("Content"); + stream.push(null); + + resource.setStream(stream); + + const p1 = resource.getBuffer(); // Trigger async transformation of stream to buffer + + const clonedResource = await resource.clone(); + t.pass("Resource cloned"); + await p1; // Wait for initial transformation to finish + + t.is(await resource.getString(), "Content", "Original resource has correct content string"); + t.is(await clonedResource.getString(), "Content", "Cloned resource has correct content string"); +}); + +test("Resource: clone resource while stream is drained/waiting for new content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource" + }); + const stream = new Stream.Readable(); + stream._read = function() {}; + stream.push("Content"); + stream.push(null); + + resource.setStream(stream); + + resource.getStream(); // Drain stream + + const p1 = resource.clone(); // Trigger async clone while stream is drained + + resource.setString("New Content"); + const clonedResource = await p1; // Wait for clone to finish + + t.is(await resource.getString(), "New Content", "Original resource has correct content string"); + t.is(await clonedResource.getString(), "New Content", "Cloned resource has correct content string"); +}); + test("Resource: clone resource with sourceMetadata", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", @@ -553,6 +892,7 @@ test("Resource: create resource with sourceMetadata.contentModified: true", (t) t.true(resource.getSourceMetadata().contentModified, "Modified flag is still true"); t.false(resource.isModified(), "Resource is not modified"); + t.falsy(resource.getLastModified(), "lastModified is not set"); }); test("getStream with createStream callback content: Subsequent content requests should throw error due " + @@ -561,9 +901,13 @@ test("getStream with createStream callback content: Subsequent content requests resource.getStream(); t.throws(() => { resource.getStream(); - }, {message: /Content of Resource \/app\/index.html has been drained/}); - await t.throwsAsync(resource.getBuffer(), {message: /Content of Resource \/app\/index.html has been drained/}); - await t.throwsAsync(resource.getString(), {message: /Content of Resource \/app\/index.html has been drained/}); + }, {message: /Content of Resource \/app\/index.html is currently flagged as drained. Consider using Resource\.getStreamAsync\(\) to wait for new content./}); + await t.throwsAsync(resource.getBuffer(), { + message: /Timeout waiting for content of Resource \/app\/index.html to become available/ + }); + await t.throwsAsync(resource.getString(), { + message: /Timeout waiting for content of Resource \/app\/index.html to become available/ + }); }); test("getStream with Buffer content: Subsequent content requests should throw error due to drained " + @@ -573,9 +917,9 @@ test("getStream with Buffer content: Subsequent content requests should throw er resource.getStream(); t.throws(() => { resource.getStream(); - }, {message: /Content of Resource \/app\/index.html has been drained/}); - await t.throwsAsync(resource.getBuffer(), {message: /Content of Resource \/app\/index.html has been drained/}); - await t.throwsAsync(resource.getString(), {message: /Content of Resource \/app\/index.html has been drained/}); + }, {message: /Content of Resource \/app\/index.html is currently flagged as drained. Consider using Resource\.getStreamAsync\(\) to wait for new content./}); + await t.throwsAsync(resource.getBuffer(), {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); + await t.throwsAsync(resource.getString(), {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); }); test("getStream with Stream content: Subsequent content requests should throw error due to drained " + @@ -594,51 +938,552 @@ test("getStream with Stream content: Subsequent content requests should throw er resource.getStream(); t.throws(() => { resource.getStream(); - }, {message: /Content of Resource \/app\/index.html has been drained/}); - await t.throwsAsync(resource.getBuffer(), {message: /Content of Resource \/app\/index.html has been drained/}); - await t.throwsAsync(resource.getString(), {message: /Content of Resource \/app\/index.html has been drained/}); + }, {message: /Content of Resource \/app\/index.html is currently flagged as drained. Consider using Resource\.getStreamAsync\(\) to wait for new content./}); + await t.throwsAsync(resource.getBuffer(), {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); + await t.throwsAsync(resource.getString(), {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); }); -test("getBuffer from Stream content: Subsequent content requests should not throw error due to drained " + - "content", async (t) => { - const resource = createBasicResource(); - const tStream = new Transform({ - transform(chunk, encoding, callback) { - this.push(chunk.toString()); - callback(); - } +test("getStream from factory content: Prefers createStream factory over createBuffer", async (t) => { + const createBufferStub = sinon.stub().resolves(Buffer.from("Buffer content")); + const createStreamStub = sinon.stub().returns( + new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + })); + const resource = new Resource({ + path: "/my/path/to/resource", + createBuffer: createBufferStub, + createStream: createStreamStub }); - const stream = resource.getStream(); - stream.pipe(tStream); - resource.setStream(tStream); - - const p1 = resource.getBuffer(); - const p2 = resource.getBuffer(); - - await t.notThrowsAsync(p1); - - // Race condition in _getBufferFromStream used to cause p2 - // to throw "Content stream of Resource /app/index.html is flagged as drained." - await t.notThrowsAsync(p2); + const stream = await resource.getStream(); + const streamedResult = await readStream(stream); + t.is(streamedResult, "Stream content", "getStream used createStream factory"); + t.true(createStreamStub.calledOnce, "createStream factory called once"); + t.false(createBufferStub.called, "createBuffer factory not called"); }); -test("Resource: getProject", (t) => { - t.plan(1); +test("getStreamAsync with Buffer content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", - project: {getName: () => "Mock Project"} + buffer: Buffer.from("Content") }); - const project = resource.getProject(); - t.is(project.getName(), "Mock Project"); + + const stream = await resource.getStreamAsync(); + const result = await readStream(stream); + t.is(result, "Content", "Stream has been read correctly"); }); -test("Resource: setProject", (t) => { - t.plan(1); +test("getStreamAsync with createStream callback", async (t) => { + const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); const resource = new Resource({ - path: "/my/path/to/resource" + path: "/my/path/to/resource", + createStream: () => { + return createReadStream(fsPath); + } }); - const project = {getName: () => "Mock Project"}; - resource.setProject(project); + + const stream = await resource.getStreamAsync(); + const result = await readStream(stream); + t.is(result.length, 91, "Stream content has correct length"); +}); + +test("getStreamAsync with Stream content", async (t) => { + const stream = new Stream.Readable(); + stream._read = function() {}; + stream.push("Stream "); + stream.push("content!"); + stream.push(null); + + const resource = new Resource({ + path: "/my/path/to/resource", + stream + }); + + const resultStream = await resource.getStreamAsync(); + const result = await readStream(resultStream); + t.is(result, "Stream content!", "Stream has been read correctly"); +}); + +test("getStreamAsync: Factory content can be used to create new streams after setting new content", async (t) => { + const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); + const createStreamStub = sinon.stub().returns( + new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + })); + const resource = new Resource({ + path: "/my/path/to/resource", + createStream: createStreamStub, + }); + + // First call creates a stream + const stream1 = await resource.getStreamAsync(); + const result1 = await readStream(stream1); + t.is(result1.length, 14, "First stream read successfully"); + t.is(createStreamStub.callCount, 1, "Factory called once"); + + // Content is now drained. To call getStreamAsync again, we need to set new content + // by calling setStream with the factory again + resource.setStream(() => createReadStream(fsPath)); + + const stream2 = await resource.getStreamAsync(); + const result2 = await readStream(stream2); + t.is(result2.length, 91, "Second stream read successfully after resetting content"); +}); + +test("getStreamAsync: Waits for new content after stream is drained", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "Initial content" + }); + + const stream1 = await resource.getStreamAsync(); + const result1 = await readStream(stream1); + t.is(result1, "Initial content", "First stream read successfully"); + + // Content is now drained, set new content + setTimeout(() => { + resource.setString("New content"); + }, 10); + + const stream2 = await resource.getStreamAsync(); + const result2 = await readStream(stream2); + t.is(result2, "New content", "Second stream read successfully after setting new content"); +}); + +test("getStreamAsync: Waits for content transformation to complete", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Initial content"); + this.push(null); + } + }) + }); + + // Start getBuffer which will transform content + const bufferPromise = resource.getBuffer(); + + // Immediately call getStreamAsync while transformation is in progress + const streamPromise = resource.getStreamAsync(); + + // Both should complete successfully + await bufferPromise; + const stream = await streamPromise; + const result = await readStream(stream); + t.is(result, "Initial content", "Stream read successfully after waiting for transformation"); +}); + +test("getStreamAsync with no content throws error", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource" + }); + + await t.throwsAsync(resource.getStreamAsync(), { + message: "Resource /my/path/to/resource has no content" + }); +}); + +test("getStreamAsync from factory content: Prefers createStream factory", async (t) => { + const createBufferStub = sinon.stub().resolves(Buffer.from("Buffer content")); + const createStreamStub = sinon.stub().returns( + new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + })); + const resource = new Resource({ + path: "/my/path/to/resource", + createBuffer: createBufferStub, + createStream: createStreamStub + }); + + const stream = await resource.getStreamAsync(); + const streamedResult = await readStream(stream); + t.is(streamedResult, "Stream content", "getStreamAsync used createStream factory"); + t.true(createStreamStub.calledOnce, "createStream factory called once"); + t.false(createBufferStub.called, "createBuffer factory not called"); +}); + +test("modifyStream: Modify buffer content with transform stream", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "hello world" + }); + + t.false(resource.isModified(), "Resource is not modified initially"); + + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + return Buffer.from(content.toUpperCase()); + }); + + const result = await resource.getString(); + t.is(result, "HELLO WORLD", "Content was modified correctly"); + t.true(resource.isModified(), "Resource is marked as modified"); +}); + +test("modifyStream: Return new stream from callback", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "test content" + }); + + await resource.modifyStream((stream) => { + const transformStream = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk.toString().toUpperCase()); + callback(); + } + }); + stream.pipe(transformStream); + return transformStream; + }); + + const result = await resource.getString(); + t.is(result, "TEST CONTENT", "Content was modified with transform stream"); +}); + +test("modifyStream: Can modify multiple times", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "test" + }); + + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + return Buffer.from(content + " modified"); + }); + + t.is(await resource.getString(), "test modified", "First modification applied"); + + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + return Buffer.from(content + " again"); + }); + + t.is(await resource.getString(), "test modified again", "Second modification applied"); +}); + +test("modifyStream: Works with factory content", async (t) => { + const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); + const resource = new Resource({ + path: "/my/path/to/resource", + createStream: () => createReadStream(fsPath) + }); + + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + return Buffer.from(content.toUpperCase()); + }); + + const result = await resource.getString(); + t.true(result.includes(""), "Content was read and modified from factory"); +}); + +test("modifyStream: Waits for drained content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "initial" + }); + + // Drain the content + const stream1 = await resource.getStreamAsync(); + await readStream(stream1); + + // Set new content after a delay + setTimeout(() => { + resource.setString("new content"); + }, 10); + + // modifyStream should wait for new content + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + return Buffer.from(content.toUpperCase()); + }); + + const result = await resource.getString(); + t.is(result, "NEW CONTENT", "modifyStream waited for new content and modified it"); +}); + +test("modifyStream: Locks content during modification", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "test" + }); + + const modifyPromise = resource.modifyStream(async (stream) => { + // Simulate slow transformation + await new Promise((resolve) => setTimeout(resolve, 20)); + const content = await readStream(stream); + return Buffer.from(content.toUpperCase()); + }); + + // Try to access content while modification is in progress + // This should wait for the lock to be released + const bufferPromise = resource.getBuffer(); + + await modifyPromise; + const buffer = await bufferPromise; + + t.is(buffer.toString(), "TEST", "Content access waited for modification to complete"); +}); + +test("modifyStream: Throws error if callback returns invalid content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "test" + }); + + await t.throwsAsync( + resource.modifyStream(async (stream) => { + return "not a buffer or stream"; + }), + { + message: "Unable to set new content: Content must be either a Buffer or a Readable Stream" + } + ); +}); + +test("modifyStream: Async callback returning Promise", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "async test" + }); + + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 5)); + return Buffer.from(content.replace("async", "ASYNC")); + }); + + const result = await resource.getString(); + t.is(result, "ASYNC test", "Async callback worked correctly"); +}); + +test("modifyStream: Sync callback returning Buffer", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "sync test" + }); + + await resource.modifyStream((stream) => { + // Return buffer synchronously + return Buffer.from("SYNC TEST"); + }); + + const result = await resource.getString(); + t.is(result, "SYNC TEST", "Sync callback returning Buffer worked correctly"); +}); + +test("modifyStream: Updates modified flag", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "test", + sourceMetadata: {} + }); + + t.false(resource.isModified(), "Resource is not marked as modified"); + t.false(resource.getSourceMetadata().contentModified, "contentModified is false initially"); + + await resource.modifyStream(async (stream) => { + const content = await readStream(stream); + return Buffer.from(content.toUpperCase()); + }); + + t.true(resource.isModified(), "Resource is marked as modified"); + t.true(resource.getSourceMetadata().contentModified, "contentModified is true after modification"); +}); + +test("getBuffer from Stream content with known size", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + byteSize: 14, + stream: new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + }) + }); + + const p1 = resource.getBuffer(); + const p2 = resource.getBuffer(); + + t.is((await p1).toString(), "Stream content"); + t.is((await p2).toString(), "Stream content"); +}); + +test("getBuffer from Stream content with incorrect size", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + byteSize: 80, + stream: new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + }) + }); + + await await t.throwsAsync(resource.getBuffer(), { + message: `Stream ended early: expected 80 bytes, got 14` + }, `Threw with expected error message`); + + const resource2 = new Resource({ + path: "/my/path/to/resource", + byteSize: 1, + stream: new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + }) + }); + + await await t.throwsAsync(resource2.getBuffer(), { + message: `Stream exceeded expected size: 1, got at least 14` + }, `Threw with expected error message`); +}); + +test("getBuffer from Stream content with stream error", async (t) => { + let destroyCalled = false; + const resource = new Resource({ + path: "/my/path/to/resource", + byteSize: 14, + stream: new Stream.Readable({ + read() { + this.emit("error", new Error("Stream failure")); + }, + destroy(err, callback) { + destroyCalled = true; + // The error will be present when stream.destroy is called due to the error + t.truthy(err, "destroy called with error"); + callback(err); + } + }) + }); + + await t.throwsAsync(resource.getBuffer()); + t.true(destroyCalled, "Stream destroy was called due to error"); +}); + +test("getBuffer from Stream content: Subsequent content requests should not throw error due to drained " + + "content", async (t) => { + const resource = createBasicResource(); + const tStream = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk.toString()); + callback(); + } + }); + const stream = resource.getStream(); + stream.pipe(tStream); + resource.setStream(tStream); + + const p1 = resource.getBuffer(); + const p2 = resource.getBuffer(); + + await t.notThrowsAsync(p1); + + // Race condition in _getBufferFromStream used to cause p2 + // to throw "Content stream of Resource /app/index.html is flagged as drained." + await t.notThrowsAsync(p2); +}); + +test("getBuffer from Stream content: getBuffer call while stream is consumed and new content is not yet set", + async (t) => { + const resource = createBasicResource(); + const tStream = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk.toString()); + callback(); + } + }); + const stream = resource.getStream(); + const p1 = resource.getBuffer(); + stream.pipe(tStream); + resource.setStream(tStream); + + const p2 = resource.getBuffer(); + + await t.notThrowsAsync(p1); + + // Race condition in _getBufferFromStream used to cause p2 + // to throw "Content stream of Resource /app/index.html is flagged as drained." + await t.notThrowsAsync(p2); + }); + +test("getBuffer from factory content: Prefers createBuffer factory over createStream", async (t) => { + const createBufferStub = sinon.stub().resolves(Buffer.from("Buffer content")); + const createStreamStub = sinon.stub().returns( + new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + })); + const resource = new Resource({ + path: "/my/path/to/resource", + createBuffer: createBufferStub, + createStream: createStreamStub + }); + const buffer = await resource.getBuffer(); + t.is(buffer.toString(), "Buffer content", "getBuffer used createBuffer factory"); + t.true(createBufferStub.calledOnce, "createBuffer factory called once"); + t.false(createStreamStub.called, "createStream factory not called"); + + // Calling getBuffer again should not call factories again + const buffer2 = await resource.getBuffer(); + t.is(buffer2, buffer, "getBuffer returned same buffer instance"); + t.true(createBufferStub.calledOnce, "createBuffer factory still called only once"); +}); + +test("getBuffer from factory content: Factory does not return buffer instance", async (t) => { + const createBufferStub = sinon.stub().resolves("Buffer content"); + const createStreamStub = sinon.stub().returns( + new Stream.Readable({ + read() { + this.push("Stream content"); + this.push(null); + } + })); + const resource = new Resource({ + path: "/my/path/to/resource", + createBuffer: createBufferStub, + createStream: createStreamStub + }); + await t.throwsAsync(resource.getBuffer(), { + message: `Buffer factory of Resource /my/path/to/resource did not return a Buffer instance` + }, `Threw with expected error message`); + t.true(createBufferStub.calledOnce, "createBuffer factory called once"); + t.false(createStreamStub.called, "createStream factory not called"); +}); + +test("Resource: getProject", (t) => { + t.plan(1); + const resource = new Resource({ + path: "/my/path/to/resource", + project: {getName: () => "Mock Project"} + }); + const project = resource.getProject(); + t.is(project.getName(), "Mock Project"); +}); + +test("Resource: setProject", (t) => { + t.plan(1); + const resource = new Resource({ + path: "/my/path/to/resource" + }); + const project = {getName: () => "Mock Project"}; + resource.setProject(project); t.is(resource.getProject().getName(), "Mock Project"); }); @@ -665,6 +1510,7 @@ test("Resource: constructor with stream", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", stream, + byteSize: 23, sourceMetadata: {} // Needs to be passed in order to get the "modified" state }); @@ -677,9 +1523,9 @@ test("Resource: constructor with stream", async (t) => { t.is(resource.getSourceMetadata().contentModified, false); }); -test("integration stat - resource size", async (t) => { +test("integration stat", async (t) => { const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); - const statInfo = await fs.stat(fsPath); + const statInfo = await stat(fsPath); const resource = new Resource({ path: "/some/path", @@ -689,11 +1535,295 @@ test("integration stat - resource size", async (t) => { } }); t.is(await resource.getSize(), 91); + t.false(resource.isDirectory()); + t.is(resource.getLastModified(), statInfo.mtimeMs); // Setting the same content again should end up with the same size resource.setString(await resource.getString()); t.is(await resource.getSize(), 91); + t.true(resource.getLastModified() > statInfo.mtimeMs, "lastModified should be updated"); resource.setString("myvalue"); t.is(await resource.getSize(), 7); }); + +test("getSize", async (t) => { + const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); + const statInfo = await stat(fsPath); + + const resource = new Resource({ + path: "/some/path", + byteSize: statInfo.size, + createStream: () => { + return createReadStream(fsPath); + } + }); + t.true(resource.hasSize()); + t.is(await resource.getSize(), 91); + + const resourceNoSize = new Resource({ + path: "/some/path", + createStream: () => { + return createReadStream(fsPath); + } + }); + t.false(resourceNoSize.hasSize(), "Resource with createStream and no byteSize has no size"); + t.is(await resourceNoSize.getSize(), 91); +}); + +/* Hash Glossary + + "Content" = "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + "New content" = "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=" +*/ +test("getHash: Throws error for directory resource", async (t) => { + const resource = new Resource({ + path: "/my/directory", + isDirectory: true + }); + + await t.throwsAsync(resource.getHash(), { + message: "Unable to calculate hash for directory resource: /my/directory" + }); +}); + +test("getHash: Returns hash for buffer content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + buffer: Buffer.from("Content") + }); + + const hash = await resource.getHash(); + t.is(typeof hash, "string", "Hash is a string"); + t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); + t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash for content"); +}); + +test("getHash: Returns hash for stream content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Content"); + this.push(null); + } + }), + }); + + const hash = await resource.getHash(); + t.is(typeof hash, "string", "Hash is a string"); + t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); + t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash for content"); +}); + +test("getHash: Returns hash for factory content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + createStream: () => { + return new Stream.Readable({ + read() { + this.push("Content"); + this.push(null); + } + }); + } + }); + + const hash = await resource.getHash(); + t.is(typeof hash, "string", "Hash is a string"); + t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); + t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash for content"); +}); + +test("getHash: Throws error for resource with no content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource" + }); + + await t.throwsAsync(resource.getHash(), { + message: "Resource /my/path/to/resource has no content" + }); +}); + +test("getHash: Different content produces different hashes", async (t) => { + const resource1 = new Resource({ + path: "/my/path/to/resource1", + string: "Content 1" + }); + + const resource2 = new Resource({ + path: "/my/path/to/resource2", + string: "Content 2" + }); + + const hash1 = await resource1.getHash(); + const hash2 = await resource2.getHash(); + + t.not(hash1, hash2, "Different content produces different hashes"); +}); + +test("getHash: Same content produces same hash", async (t) => { + const resource1 = new Resource({ + path: "/my/path/to/resource1", + string: "Content" + }); + + const resource2 = new Resource({ + path: "/my/path/to/resource2", + buffer: Buffer.from("Content") + }); + + const resource3 = new Resource({ + path: "/my/path/to/resource2", + stream: new Stream.Readable({ + read() { + this.push("Content"); + this.push(null); + } + }), + }); + + const hash1 = await resource1.getHash(); + const hash2 = await resource2.getHash(); + const hash3 = await resource3.getHash(); + + t.is(hash1, hash2, "Same content produces same hash for string and buffer content"); + t.is(hash1, hash3, "Same content produces same hash for string and stream"); +}); + +test("getHash: Waits for drained content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "Initial content" + }); + + // Drain the stream + await resource.getStream(); + const p1 = resource.getHash(); // Start getHash which should wait for new content + + resource.setString("New content"); + + const hash = await p1; + t.is(hash, "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=", "Correct hash for new content"); +}); + +test("getHash: Waits for content transformation to complete", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Content"); + this.push(null); + } + }) + }); + + // Start getBuffer which will transform content + const bufferPromise = resource.getBuffer(); + + // Immediately call getHash while transformation is in progress + const hashPromise = resource.getHash(); + + // Both should complete successfully + await bufferPromise; + const hash = await hashPromise; + t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash after waiting for transformation"); +}); + +test("getHash: Can be called multiple times on buffer content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + buffer: Buffer.from("Content") + }); + + const hash1 = await resource.getHash(); + const hash2 = await resource.getHash(); + const hash3 = await resource.getHash(); + + t.is(hash1, hash2, "First and second hash are identical"); + t.is(hash2, hash3, "Second and third hash are identical"); + t.is(hash1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash value"); +}); + +test("getHash: Can be called multiple times on factory content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + createStream: () => { + return new Stream.Readable({ + read() { + this.push("Content"); + this.push(null); + } + }); + } + }); + + const hash1 = await resource.getHash(); + const hash2 = await resource.getHash(); + const hash3 = await resource.getHash(); + + t.is(hash1, hash2, "First and second hash are identical"); + t.is(hash2, hash3, "Second and third hash are identical"); + t.is(hash1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash value"); +}); + +test("getHash: Can only be called once on stream content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + stream: new Stream.Readable({ + read() { + this.push("Content"); + this.push(null); + } + }) + }); + + const hash1 = await resource.getHash(); + await t.throwsAsync(resource.getHash(), { + message: /Timeout waiting for content of Resource \/my\/path\/to\/resource to become available./ + }, `Threw with expected error message`); + + t.is(hash1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash value"); +}); + +test("getHash: Hash changes after content modification", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "Original content" + }); + + const hash1 = await resource.getHash(); + t.is(hash1, "sha256-OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=", "Correct hash for original content"); + + resource.setString("Modified content"); + + const hash2 = await resource.getHash(); + t.is(hash2, "sha256-8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=", "Hash changes after modification"); + t.not(hash1, hash2, "New hash is different from original"); +}); + +test("getHash: Works with empty content", async (t) => { + const resource = new Resource({ + path: "/my/path/to/resource", + string: "" + }); + + const hash = await resource.getHash(); + t.is(typeof hash, "string", "Hash is a string"); + t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); + t.is(hash, "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "Correct hash for empty content"); +}); + +test("getHash: Works with large content", async (t) => { + const largeContent = "x".repeat(1024 * 1024); // 1MB of 'x' + const resource = new Resource({ + path: "/my/path/to/resource", + string: largeContent + }); + + const hash = await resource.getHash(); + t.is(typeof hash, "string", "Hash is a string"); + t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); + // Hash of 1MB of 'x' characters + t.is(hash, "sha256-j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=", "Correct hash for large content"); +}); diff --git a/packages/fs/test/lib/ResourceFacade.js b/packages/fs/test/lib/ResourceFacade.js index 5dee2fc8f1c..cabaa1b6748 100644 --- a/packages/fs/test/lib/ResourceFacade.js +++ b/packages/fs/test/lib/ResourceFacade.js @@ -17,6 +17,7 @@ test("Create instance", (t) => { resource }); t.is(resourceFacade.getPath(), "/my/path", "Returns correct path"); + t.is(resourceFacade.getOriginalPath(), "/my/path/to/resource", "Returns correct original path"); t.is(resourceFacade.getName(), "path", "Returns correct name"); t.is(resourceFacade.getConcealedResource(), resource, "Returns correct concealed resource"); }); @@ -86,7 +87,7 @@ test("ResourceFacade provides same public functions as Resource", (t) => { methods.forEach((method) => { t.truthy(resourceFacade[method], `resourceFacade provides function #${method}`); - if (["constructor", "getPath", "getName", "setPath", "clone"].includes(method)) { + if (["constructor", "getPath", "getOriginalPath", "getName", "setPath", "clone"].includes(method)) { // special functions with separate tests return; } diff --git a/packages/fs/test/lib/adapters/FileSystem_write.js b/packages/fs/test/lib/adapters/FileSystem_write.js index 8386c2a5487..33b3a72f021 100644 --- a/packages/fs/test/lib/adapters/FileSystem_write.js +++ b/packages/fs/test/lib/adapters/FileSystem_write.js @@ -116,7 +116,7 @@ test("Write modified resource in drain mode", async (t) => { await t.notThrowsAsync(fileEqual(t, destFsPath, "./test/fixtures/application.a/webapp/index.html")); await t.throwsAsync(resource.getBuffer(), - {message: /Content of Resource \/app\/index.html has been drained/}); + {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); }); test("Write with readOnly and drain options set should fail", async (t) => { @@ -216,7 +216,7 @@ test("Write modified resource into same file in drain mode", async (t) => { await t.notThrowsAsync(fileEqual(t, destFsPath, "./test/fixtures/application.a/webapp/index.html")); await t.throwsAsync(resource.getBuffer(), - {message: /Content of Resource \/app\/index.html has been drained/}); + {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); }); test("Write modified resource into same file in read-only mode", async (t) => { @@ -268,7 +268,7 @@ test("Write new resource in drain mode", async (t) => { await readerWriters.dest.write(resource, {drain: true}); await t.notThrowsAsync(fileContent(t, destFsPath, "Resource content")); await t.throwsAsync(resource.getBuffer(), - {message: /Content of Resource \/app\/index.html has been drained/}); + {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); }); test("Write new resource in read-only mode", async (t) => { From 39001583a39e65ef672554f157514e22f8bfc4f9 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 1 Dec 2025 13:59:03 +0100 Subject: [PATCH 011/223] refactor(fs): Provide createBuffer factory in FileSystem adapter --- packages/fs/lib/adapters/FileSystem.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/fs/lib/adapters/FileSystem.js b/packages/fs/lib/adapters/FileSystem.js index 284d95d84a4..84c133adbc1 100644 --- a/packages/fs/lib/adapters/FileSystem.js +++ b/packages/fs/lib/adapters/FileSystem.js @@ -7,6 +7,7 @@ const copyFile = promisify(fs.copyFile); const chmod = promisify(fs.chmod); const mkdir = promisify(fs.mkdir); const stat = promisify(fs.stat); +const readFile = promisify(fs.readFile); import {globby, isGitIgnored} from "globby"; import {PassThrough} from "node:stream"; import AbstractAdapter from "./AbstractAdapter.js"; @@ -129,6 +130,9 @@ class FileSystem extends AbstractAdapter { }, createStream: () => { return fs.createReadStream(fsPath); + }, + createBuffer: () => { + return readFile(fsPath); } })); } @@ -202,6 +206,9 @@ class FileSystem extends AbstractAdapter { resourceOptions.createStream = function() { return fs.createReadStream(fsPath); }; + resourceOptions.createBuffer = function() { + return readFile(fsPath); + }; } return this._createResource(resourceOptions); From 3ab54c8c58ddbb2a7709abffc6f1665609734ef8 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 1 Dec 2025 14:36:14 +0100 Subject: [PATCH 012/223] refactor(project): Refactor cache classes --- packages/project/lib/build/TaskRunner.js | 3 + .../project/lib/build/cache/BuildTaskCache.js | 126 ++++++++++++- .../lib/build/cache/ProjectBuildCache.js | 176 +++++++++++++++--- 3 files changed, 268 insertions(+), 37 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 08473e39b2b..2dbd7c63686 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -489,6 +489,9 @@ class TaskRunner { */ async _executeTask(taskName, taskFunction, taskParams) { if (this._cache.hasValidCacheForTask(taskName)) { + // Immediately skip task if cache is valid + // Continue if cache is (potentially) invalid, in which case taskFunction will + // validate the cache thoroughly this._log.skipTask(taskName); return; } diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 1927b33e58c..a0aa46f572a 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -2,6 +2,26 @@ import micromatch from "micromatch"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:cache:BuildTaskCache"); +/** + * @typedef {object} RequestMetadata + * @property {string[]} pathsRead - Specific resource paths that were read + * @property {string[]} patterns - Glob patterns used to read resources + */ + +/** + * @typedef {object} ResourceMetadata + * @property {string} hash - Content hash of the resource + * @property {number} lastModified - Last modified timestamp (mtimeMs) + */ + +/** + * @typedef {object} TaskCacheMetadata + * @property {RequestMetadata} [projectRequests] - Project resource requests + * @property {RequestMetadata} [dependencyRequests] - Dependency resource requests + * @property {Object.} [resourcesRead] - Resources read by task + * @property {Object.} [resourcesWritten] - Resources written by task + */ + function unionArray(arr, items) { for (const item of items) { if (!arr.includes(item)) { @@ -35,6 +55,12 @@ async function createMetadataForResources(resourceMap) { return metadata; } +/** + * Manages the build cache for a single task + * + * Tracks resource reads/writes and provides methods to validate cache validity + * based on resource changes. + */ export default class BuildTaskCache { #projectName; #taskName; @@ -54,6 +80,15 @@ export default class BuildTaskCache { #resourcesRead; #resourcesWritten; + // ===== LIFECYCLE ===== + + /** + * Creates a new BuildTaskCache instance + * + * @param {string} projectName - Name of the project + * @param {string} taskName - Name of the task + * @param {TaskCacheMetadata} metadata - Task cache metadata + */ constructor(projectName, taskName, {projectRequests, dependencyRequests, resourcesRead, resourcesWritten}) { this.#projectName = projectName; this.#taskName = taskName; @@ -71,11 +106,27 @@ export default class BuildTaskCache { this.#resourcesWritten = resourcesWritten ?? Object.create(null); } + // ===== METADATA ACCESS ===== + + /** + * Gets the name of the task + * + * @returns {string} Task name + */ getTaskName() { return this.#taskName; } - updateResources(projectRequests, dependencyRequests, resourcesRead, resourcesWritten) { + /** + * Updates the task cache with new resource metadata + * + * @param {RequestMetadata} projectRequests - Project resource requests + * @param {RequestMetadata} [dependencyRequests] - Dependency resource requests + * @param {Object.} resourcesRead - Resources read by task + * @param {Object.} resourcesWritten - Resources written by task + * @returns {void} + */ + updateMetadata(projectRequests, dependencyRequests, resourcesRead, resourcesWritten) { unionArray(this.#projectRequests.pathsRead, projectRequests.pathsRead); unionArray(this.#projectRequests.patterns, projectRequests.patterns); @@ -88,7 +139,12 @@ export default class BuildTaskCache { unionObject(this.#resourcesWritten, resourcesWritten); } - async toObject() { + /** + * Serializes the task cache to a JSON-compatible object + * + * @returns {Promise} Serialized task cache data + */ + async toJSON() { return { taskName: this.#taskName, resourceMetadata: { @@ -100,7 +156,19 @@ export default class BuildTaskCache { }; } - checkPossiblyInvalidatesTask(projectResourcePaths, dependencyResourcePaths) { + // ===== VALIDATION ===== + + /** + * Checks if changed resources match this task's tracked resources + * + * This is a fast check that determines if the task *might* be invalidated + * based on path matching and glob patterns. + * + * @param {Set|string[]} projectResourcePaths - Changed project resource paths + * @param {Set|string[]} dependencyResourcePaths - Changed dependency resource paths + * @returns {boolean} True if any changed resources match this task's tracked resources + */ + matchesChangedResources(projectResourcePaths, dependencyResourcePaths) { if (this.#isRelevantResourceChange(this.#projectRequests, projectResourcePaths)) { log.verbose( `Build cache for task ${this.#taskName} of project ${this.#projectName} possibly invalidated ` + @@ -118,15 +186,35 @@ export default class BuildTaskCache { return false; } - getReadResourceCacheEntry(searchResourcePath) { + // ===== CACHE LOOKUPS ===== + + /** + * Gets the cache entry for a resource that was read + * + * @param {string} searchResourcePath - Path of the resource to look up + * @returns {ResourceMetadata|object|undefined} Cache entry or undefined if not found + */ + getReadCacheEntry(searchResourcePath) { return this.#resourcesRead[searchResourcePath]; } - getWrittenResourceCache(searchResourcePath) { + /** + * Gets the cache entry for a resource that was written + * + * @param {string} searchResourcePath - Path of the resource to look up + * @returns {ResourceMetadata|object|undefined} Cache entry or undefined if not found + */ + getWriteCacheEntry(searchResourcePath) { return this.#resourcesWritten[searchResourcePath]; } - async isResourceInReadCache(resource) { + /** + * Checks if a resource exists in the read cache and has the same content + * + * @param {object} resource - Resource instance to check + * @returns {Promise} True if resource is in cache with matching content + */ + async hasResourceInReadCache(resource) { const cachedResource = this.#resourcesRead[resource.getPath()]; if (!cachedResource) { return false; @@ -138,7 +226,13 @@ export default class BuildTaskCache { } } - async isResourceInWriteCache(resource) { + /** + * Checks if a resource exists in the write cache and has the same content + * + * @param {object} resource - Resource instance to check + * @returns {Promise} True if resource is in cache with matching content + */ + async hasResourceInWriteCache(resource) { const cachedResource = this.#resourcesWritten[resource.getPath()]; if (!cachedResource) { return false; @@ -150,6 +244,14 @@ export default class BuildTaskCache { } } + /** + * Compares two resource instances for equality + * + * @param {object} resourceA - First resource to compare + * @param {object} resourceB - Second resource to compare + * @returns {Promise} True if resources are equal + * @throws {Error} If either resource is undefined + */ async #isResourceEqual(resourceA, resourceB) { if (!resourceA || !resourceB) { throw new Error("Cannot compare undefined resources"); @@ -157,7 +259,7 @@ export default class BuildTaskCache { if (resourceA === resourceB) { return true; } - if (resourceA.getStatInfo()?.mtimeMs !== resourceA.getStatInfo()?.mtimeMs) { + if (resourceA.getStatInfo()?.mtimeMs !== resourceB.getStatInfo()?.mtimeMs) { return false; } if (await resourceA.getString() === await resourceB.getString()) { @@ -166,6 +268,14 @@ export default class BuildTaskCache { return false; } + /** + * Compares a resource instance with cached metadata fingerprint + * + * @param {object} resourceA - Resource instance to compare + * @param {ResourceMetadata} resourceBMetadata - Cached metadata to compare against + * @returns {Promise} True if resource matches the fingerprint + * @throws {Error} If resource or metadata is undefined + */ async #isResourceFingerprintEqual(resourceA, resourceBMetadata) { if (!resourceA || !resourceBMetadata) { throw new Error("Cannot compare undefined resources"); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 3fc87b06afe..4d8fe8c2ee0 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -36,10 +36,11 @@ export default class ProjectBuildCache { #restoreFailed = false; /** + * Creates a new ProjectBuildCache instance * - * @param {Project} project Project instance - * @param {string} cacheKey Cache key - * @param {string} [cacheDir] Cache directory + * @param {object} project - Project instance + * @param {string} cacheKey - Cache key identifying this build configuration + * @param {string} [cacheDir] - Optional cache directory for persistence */ constructor(project, cacheKey, cacheDir) { this.#project = project; @@ -51,7 +52,22 @@ export default class ProjectBuildCache { }); } - async updateTaskResult(taskName, workspaceTracker, dependencyTracker) { + // ===== TASK MANAGEMENT ===== + + /** + * Records the result of a task execution and updates the cache + * + * This method: + * 1. Stores metadata about resources read/written by the task + * 2. Detects which resources have actually changed + * 3. Invalidates downstream tasks if necessary + * + * @param {string} taskName - Name of the executed task + * @param {object} workspaceTracker - Tracker that monitored workspace reads + * @param {object} [dependencyTracker] - Tracker that monitored dependency reads + * @returns {Promise} + */ + async recordTaskResult(taskName, workspaceTracker, dependencyTracker) { const projectTrackingResults = workspaceTracker.getResults(); const dependencyTrackingResults = dependencyTracker?.getResults(); @@ -74,7 +90,7 @@ export default class ProjectBuildCache { const changedPaths = new Set((await Promise.all(writtenResourcePaths .map(async (resourcePath) => { // Check whether resource content actually changed - if (await taskCache.isResourceInWriteCache(resourcesWritten[resourcePath])) { + if (await taskCache.hasResourceInWriteCache(resourcesWritten[resourcePath])) { return undefined; } return resourcePath; @@ -97,7 +113,7 @@ export default class ProjectBuildCache { const emptySet = new Set(); for (let i = taskIndex + 1; i < allTasks.length; i++) { const nextTaskName = allTasks[i]; - if (!this.#taskCache.get(nextTaskName).checkPossiblyInvalidatesTask(changedPaths, emptySet)) { + if (!this.#taskCache.get(nextTaskName).matchesChangedResources(changedPaths, emptySet)) { continue; } if (this.#invalidatedTasks.has(taskName)) { @@ -114,7 +130,7 @@ export default class ProjectBuildCache { } } } - taskCache.updateResources( + taskCache.updateMetadata( projectTrackingResults.requests, dependencyTrackingResults?.requests, resourcesRead, @@ -137,7 +153,27 @@ export default class ProjectBuildCache { } } - harvestUpdatedResources() { + /** + * Returns the task cache for a specific task + * + * @param {string} taskName - Name of the task + * @returns {BuildTaskCache|undefined} The task cache or undefined if not found + */ + getTaskCache(taskName) { + return this.#taskCache.get(taskName); + } + + // ===== INVALIDATION ===== + + /** + * Collects all modified resource paths and clears the internal tracking set + * + * Note: This method has side effects - it clears the internal modified resources set. + * Call this only when you're ready to consume and process all accumulated changes. + * + * @returns {Set} Set of resource paths that have been modified + */ + collectAndClearModifiedPaths() { const updatedResources = new Set(this.#updatedResources); this.#updatedResources.clear(); return updatedResources; @@ -169,7 +205,19 @@ export default class ProjectBuildCache { return taskInvalidated; } - async validateChangedProjectResources(taskName, workspace, dependencies) { + /** + * Validates whether supposedly changed resources have actually changed + * + * Performs fine-grained validation by comparing resource content (hash/mtime) + * and removes false positives from the invalidation set. + * + * @param {string} taskName - Name of the task to validate + * @param {object} workspace - Workspace reader + * @param {object} dependencies - Dependencies reader + * @returns {Promise} + * @throws {Error} If task cache not found for the given taskName + */ + async validateChangedResources(taskName, workspace, dependencies) { // Check whether the supposedly changed resources for the task have actually changed if (!this.#invalidatedTasks.has(taskName)) { return; @@ -196,7 +244,7 @@ export default class ProjectBuildCache { if (!taskCache) { throw new Error(`Failed to validate changed resources for task ${taskName}: Task cache not found`); } - if (await taskCache.isResourceInReadCache(resource)) { + if (await taskCache.hasResourceInReadCache(resource)) { log.verbose(`Resource content has not changed for task ${taskName}, ` + `removing ${resourcePath} from set of changed resource paths`); changedResourcePaths.delete(resourcePath); @@ -204,36 +252,91 @@ export default class ProjectBuildCache { } } - getChangedProjectResourcePaths(taskName) { + /** + * Gets the set of changed project resource paths for a task + * + * @param {string} taskName - Name of the task + * @returns {Set} Set of changed project resource paths + */ + getChangedProjectPaths(taskName) { return this.#invalidatedTasks.get(taskName)?.changedProjectResourcePaths ?? new Set(); } - getChangedDependencyResourcePaths(taskName) { + /** + * Gets the set of changed dependency resource paths for a task + * + * @param {string} taskName - Name of the task + * @returns {Set} Set of changed dependency resource paths + */ + getChangedDependencyPaths(taskName) { return this.#invalidatedTasks.get(taskName)?.changedDependencyResourcePaths ?? new Set(); } - hasCache() { + // ===== CACHE QUERIES ===== + + /** + * Checks if any task cache exists + * + * @returns {boolean} True if at least one task has been cached + */ + hasAnyCache() { return this.#taskCache.size > 0; } - /* - Check whether the project's build cache has an entry for the given stage. - This means that the cache has been filled with the output of the given stage. - */ - hasCacheForTask(taskName) { + /** + * Checks whether the project's build cache has an entry for the given task + * + * This means that the cache has been filled with the input and output of the given task. + * + * @param {string} taskName - Name of the task + * @returns {boolean} True if cache exists for this task + */ + hasTaskCache(taskName) { return this.#taskCache.has(taskName); } - hasValidCacheForTask(taskName) { + /** + * Checks whether the cache for a specific task is currently valid + * + * @param {string} taskName - Name of the task + * @returns {boolean} True if cache exists and is valid for this task + */ + isTaskCacheValid(taskName) { return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName); } - getCacheForTask(taskName) { - return this.#taskCache.get(taskName); + /** + * Determines whether a rebuild is needed + * + * @returns {boolean} True if no cache exists or if any tasks have been invalidated + */ + needsRebuild() { + return !this.hasAnyCache() || this.#invalidatedTasks.size > 0; } - requiresBuild() { - return !this.hasCache() || this.#invalidatedTasks.size > 0; + /** + * Gets the current status of the cache for debugging and monitoring + * + * @returns {object} Status information including cache state and statistics + */ + getStatus() { + return { + hasCache: this.hasAnyCache(), + totalTasks: this.#taskCache.size, + invalidatedTasks: this.#invalidatedTasks.size, + modifiedResourceCount: this.#updatedResources.size, + cacheKey: this.#cacheKey, + restoreFailed: this.#restoreFailed + }; + } + + /** + * Gets the names of all invalidated tasks + * + * @returns {string[]} Array of task names that have been invalidated + */ + getInvalidatedTaskNames() { + return Array.from(this.#invalidatedTasks.keys()); } async toObject() { @@ -255,7 +358,7 @@ export default class ProjectBuildCache { // } const taskCache = []; for (const cache of this.#taskCache.values()) { - const cacheObject = await cache.toObject(); + const cacheObject = await cache.toJSON(); taskCache.push(cacheObject); // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesRead); // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesWritten); @@ -283,7 +386,7 @@ export default class ProjectBuildCache { } async #serializeMetadata() { - const serializedCache = await this.toObject(); + const serializedCache = await this.toJSON(); const cacheContent = JSON.stringify(serializedCache, null, 2); const res = createResource({ path: `/cache-info.json`, @@ -351,8 +454,8 @@ export default class ProjectBuildCache { }*/ } if (changedResources.size) { - const tasksInvalidated = this.resourceChanged(changedResources, new Set()); - if (tasksInvalidated) { + const invalidatedTasks = this.markResourcesChanged(changedResources, new Set()); + if (invalidatedTasks.length > 0) { log.info(`Invalidating tasks due to changed resources for project ${this.#project.getName()}`); } } @@ -379,7 +482,13 @@ export default class ProjectBuildCache { this.#project.importCachedStages(cachedStages); } - async serializeToDisk() { + /** + * Saves the cache to disk + * + * @returns {Promise} + * @throws {Error} If cache persistence is not available + */ + async saveToDisk() { if (!this.#cacheRoot) { log.error("Cannot save cache to disk: No cache persistence available"); return; @@ -390,7 +499,16 @@ export default class ProjectBuildCache { ]); } - async attemptDeserializationFromDisk() { + /** + * Attempts to load the cache from disk + * + * If a cache file exists, it will be loaded and validated. If any source files + * have changed since the cache was created, affected tasks will be invalidated. + * + * @returns {Promise} + * @throws {Error} If cache restoration fails + */ + async loadFromDisk() { if (this.#restoreFailed || !this.#cacheRoot) { return; } From aa5b4550be6ce81cb4eb0981a01df061cc2f0045 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 4 Dec 2025 11:18:41 +0100 Subject: [PATCH 013/223] refactor(fs): Add Proxy reader --- packages/fs/lib/Resource.js | 41 ++++++++-- packages/fs/lib/readers/Filter.js | 1 + packages/fs/lib/readers/Link.js | 1 + packages/fs/lib/readers/Proxy.js | 126 +++++++++++++++++++++++++++++ packages/fs/lib/resourceFactory.js | 14 ++++ 5 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 packages/fs/lib/readers/Proxy.js diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index 19c937ba4b4..228f365c86b 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -47,6 +47,8 @@ class Resource { #lastModified; #statInfo; #isDirectory; + #integrity; + #inode; /* States */ #isModified = false; @@ -91,10 +93,12 @@ class Resource { * @param {boolean} [parameters.isDirectory] Flag whether the resource represents a directory * @param {number} [parameters.byteSize] Size of the resource content in bytes * @param {number} [parameters.lastModified] Last modified timestamp (in milliseconds since UNIX epoch) + * @param {string} [parameters.integrity] Integrity hash of the resource content + * @param {number} [parameters.inode] Inode number of the resource */ constructor({ path, statInfo, buffer, createBuffer, string, createStream, stream, project, sourceMetadata, - isDirectory, byteSize, lastModified, + isDirectory, byteSize, lastModified, integrity, inode, }) { if (!path) { throw new Error("Unable to create Resource: Missing parameter 'path'"); @@ -140,6 +144,7 @@ class Resource { this.#sourceMetadata.contentModified ??= false; this.#project = project; + this.#integrity = integrity; if (createStream) { // We store both factories individually @@ -193,6 +198,13 @@ class Resource { this.#lastModified = lastModified; } + if (inode !== undefined) { + if (typeof inode !== "number" || inode < 0) { + throw new Error("Unable to create Resource: Parameter 'inode' must be a positive number"); + } + this.#inode = inode; + } + if (statInfo) { this.#isDirectory ??= statInfo.isDirectory(); if (!this.#isDirectory && statInfo.isFile && !statInfo.isFile()) { @@ -200,6 +212,7 @@ class Resource { } this.#byteSize ??= statInfo.size; this.#lastModified ??= statInfo.mtimeMs; + this.#inode ??= statInfo.ino; // Create legacy statInfo object this.#statInfo = parseStat(statInfo); @@ -518,7 +531,10 @@ class Resource { this.#contendModified(); } - async getHash() { + async getIntegrity() { + if (this.#integrity) { + return this.#integrity; + } if (this.isDirectory()) { throw new Error(`Unable to calculate hash for directory resource: ${this.#path}`); } @@ -535,13 +551,16 @@ class Resource { switch (this.#contentType) { case CONTENT_TYPES.BUFFER: - return ssri.fromData(this.#content, SSRI_OPTIONS).toString(); + this.#integrity = ssri.fromData(this.#content, SSRI_OPTIONS); + break; case CONTENT_TYPES.FACTORY: - return (await ssri.fromStream(this.#createStreamFactory(), SSRI_OPTIONS)).toString(); + this.#integrity = await ssri.fromStream(this.#createStreamFactory(), SSRI_OPTIONS); + break; case CONTENT_TYPES.STREAM: // To be discussed: Should we read the stream into a buffer here (using #getBufferFromStream) to avoid // draining it? - return (await ssri.fromStream(this.#getStream(), SSRI_OPTIONS)).toString(); + this.#integrity = ssri.fromData(await this.#getBufferFromStream(this.#content), SSRI_OPTIONS); + break; case CONTENT_TYPES.DRAINED_STREAM: throw new Error(`Unexpected error: Content of Resource ${this.#path} is flagged as drained.`); case CONTENT_TYPES.IN_TRANSFORMATION: @@ -549,6 +568,7 @@ class Resource { default: throw new Error(`Resource ${this.#path} has no content`); } + return this.#integrity; } #contendModified() { @@ -556,6 +576,7 @@ class Resource { this.#isModified = true; this.#byteSize = undefined; + this.#integrity = undefined; this.#lastModified = new Date().getTime(); // TODO: Always update or keep initial value (= fs stat)? if (this.#contentType === CONTENT_TYPES.BUFFER) { @@ -681,6 +702,16 @@ class Resource { return this.#lastModified; } + /** + * Gets the inode number of the resource. + * + * @public + * @returns {number} Inode number of the resource + */ + getInode() { + return this.#inode; + } + /** * Resource content size in bytes. * diff --git a/packages/fs/lib/readers/Filter.js b/packages/fs/lib/readers/Filter.js index 1e4cf31e727..903f43cef76 100644 --- a/packages/fs/lib/readers/Filter.js +++ b/packages/fs/lib/readers/Filter.js @@ -23,6 +23,7 @@ class Filter extends AbstractReader { * * @public * @param {object} parameters Parameters + * @param {object} parameters.name Name of the reader * @param {@ui5/fs/AbstractReader} parameters.reader The resource reader or collection to wrap * @param {@ui5/fs/readers/Filter~callback} parameters.callback * Filter function. Will be called for every resource read through this reader. diff --git a/packages/fs/lib/readers/Link.js b/packages/fs/lib/readers/Link.js index fe59fd10295..b21c7f469ae 100644 --- a/packages/fs/lib/readers/Link.js +++ b/packages/fs/lib/readers/Link.js @@ -42,6 +42,7 @@ class Link extends AbstractReader { * * @public * @param {object} parameters Parameters + * @param {object} parameters.name Name of the reader * @param {@ui5/fs/AbstractReader} parameters.reader The resource reader or collection to wrap * @param {@ui5/fs/readers/Link/PathMapping} parameters.pathMapping */ diff --git a/packages/fs/lib/readers/Proxy.js b/packages/fs/lib/readers/Proxy.js new file mode 100644 index 00000000000..23f340f27bb --- /dev/null +++ b/packages/fs/lib/readers/Proxy.js @@ -0,0 +1,126 @@ +import micromatch from "micromatch"; +import AbstractReader from "../AbstractReader.js"; + +/** + * Callback function to retrieve a resource by its virtual path. + * + * @public + * @callback @ui5/fs/readers/Proxy~getResource + * @param {string} virPath Virtual path + * @returns {Promise} Promise resolving with a Resource instance + */ + +/** + * Callback function to list all available virtual resource paths. + * + * @public + * @callback @ui5/fs/readers/Proxy~listResourcePaths + * @returns {Promise} Promise resolving to an array of strings (the virtual resource paths) + */ + +/** + * Generic proxy adapter. Allowing to serve resources using callback functions. Read only. + * + * @public + * @class + * @alias @ui5/fs/readers/Proxy + * @extends @ui5/fs/readers/AbstractReader + */ +class Proxy extends AbstractReader { + /** + * Constructor + * + * @public + * @param {object} parameters + * @param {object} parameters.name Name of the reader + * @param {@ui5/fs/readers/Proxy~getResource} parameters.getResource + * Callback function to retrieve a resource by its virtual path. + * @param {@ui5/fs/readers/Proxy~listResourcePaths} parameters.listResourcePaths + * Callback function to list all available virtual resource paths. + * @returns {@ui5/fs/readers/Proxy} Reader instance + */ + constructor({name, getResource, listResourcePaths}) { + super(name); + if (typeof getResource !== "function") { + throw new Error(`Proxy adapter: Missing or invalid parameter 'getResource'`); + } + if (typeof listResourcePaths !== "function") { + throw new Error(`Proxy adapter: Missing or invalid parameter 'listResourcePaths'`); + } + this._getResource = getResource; + this._listResourcePaths = listResourcePaths; + } + + async _listResourcePaths() { + const virPaths = await this._listResourcePaths(); + if (!Array.isArray(virPaths) || !virPaths.every((p) => typeof p === "string")) { + throw new Error( + `Proxy adapter: 'listResourcePaths' did not return an array of strings`); + } + return virPaths; + } + + /** + * Matches and returns resources from a given map (either _virFiles or _virDirs). + * + * @private + * @param {string[]} patterns + * @param {string[]} resourcePaths + * @returns {Promise} + */ + async _matchPatterns(patterns, resourcePaths) { + const matchedPaths = micromatch(resourcePaths, patterns, { + dot: true + }); + return await Promise.all(matchedPaths.map((virPath) => { + return this._getResource(virPath); + })); + } + + /** + * Locate resources by glob. + * + * @private + * @param {string|string[]} virPattern glob pattern as string or array of glob patterns for + * virtual directory structure + * @param {object} [options={}] glob options + * @param {boolean} [options.nodir=true] Do not match directories + * @param {@ui5/fs/tracing.Trace} trace Trace instance + * @returns {Promise<@ui5/fs/Resource[]>} Promise resolving to list of resources + */ + async _byGlob(virPattern, options = {nodir: true}, trace) { + if (!(virPattern instanceof Array)) { + virPattern = [virPattern]; + } + + if (virPattern[0] === "" && !options.nodir) { // Match virtual root directory + return [ + this._createDirectoryResource(this._virBasePath.slice(0, -1)) + ]; + } + + return await this._matchPatterns(virPattern, await this._listResourcePaths()); + } + + /** + * Locates resources by path. + * + * @private + * @param {string} virPath Virtual path + * @param {object} options Options + * @param {@ui5/fs/tracing.Trace} trace Trace instance + * @returns {Promise<@ui5/fs/Resource>} Promise resolving to a single resource + */ + async _byPath(virPath, options, trace) { + trace.pathCall(); + + const resource = await this._getResource(virPath); + if (!resource || (options.nodir && resource.getStatInfo().isDirectory())) { + return null; + } else { + return resource; + } + } +} + +export default Proxy; diff --git a/packages/fs/lib/resourceFactory.js b/packages/fs/lib/resourceFactory.js index 282b2ae4ce7..51b4f8a60df 100644 --- a/packages/fs/lib/resourceFactory.js +++ b/packages/fs/lib/resourceFactory.js @@ -9,6 +9,7 @@ import Resource from "./Resource.js"; import WriterCollection from "./WriterCollection.js"; import Filter from "./readers/Filter.js"; import Link from "./readers/Link.js"; +import Proxy from "./readers/Proxy.js"; import Tracker from "./Tracker.js"; import DuplexTracker from "./DuplexTracker.js"; import {getLogger} from "@ui5/logger"; @@ -239,6 +240,19 @@ export function createLinkReader(parameters) { return new Link(parameters); } +/** + * @param {object} parameters + * @param {object} parameters.name Name of the reader + * @param {@ui5/fs/readers/Proxy~getResource} parameters.getResource + * Callback function to retrieve a resource by its virtual path. + * @param {@ui5/fs/readers/Proxy~listResourcePaths} parameters.listResourcePaths + * Callback function to list all available virtual resource paths. + * @returns {@ui5/fs/readers/Proxy} Reader instance + */ +export function createProxy(parameters) { + return new Proxy(parameters); +} + /** * Create a [Link-Reader]{@link @ui5/fs/readers/Link} where all requests are prefixed with * /resources/<namespace>. From 0760f921176d24c89e639429019cc8354a065913 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 8 Dec 2025 10:53:27 +0100 Subject: [PATCH 014/223] refactor(project): API refactoring --- packages/project/lib/build/ProjectBuilder.js | 37 +- packages/project/lib/build/TaskRunner.js | 46 +- .../project/lib/build/cache/BuildTaskCache.js | 58 +- .../project/lib/build/cache/CacheManager.js | 127 ++++- .../lib/build/cache/ProjectBuildCache.js | 519 ++++++++++++------ packages/project/lib/build/cache/utils.js | 16 + .../project/lib/build/helpers/BuildContext.js | 26 +- .../lib/build/helpers/ProjectBuildContext.js | 72 ++- .../build/helpers/calculateBuildSignature.js | 72 +++ .../lib/build/helpers/createBuildManifest.js | 71 +-- .../project/lib/specifications/Project.js | 14 +- .../lib/specifications/extensions/Task.js | 14 + .../lib/specifications/types/Component.js | 10 +- .../project/lib/utils/sanitizeFileName.js | 44 ++ 14 files changed, 759 insertions(+), 367 deletions(-) create mode 100644 packages/project/lib/build/cache/utils.js create mode 100644 packages/project/lib/build/helpers/calculateBuildSignature.js create mode 100644 packages/project/lib/utils/sanitizeFileName.js diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 88e92cd75e4..51bf831aee8 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -5,6 +5,7 @@ import composeProjectList from "./helpers/composeProjectList.js"; import BuildContext from "./helpers/BuildContext.js"; import prettyHrtime from "pretty-hrtime"; import OutputStyleEnum from "./helpers/ProjectBuilderOutputStyle.js"; +import createBuildManifest from "./helpers/createBuildManifest.js"; /** * @public @@ -140,7 +141,6 @@ class ProjectBuilder { destPath, cleanDest = false, includedDependencies = [], excludedDependencies = [], dependencyIncludes, - cacheDir, watch, }) { if (!destPath && !watch) { @@ -179,7 +179,7 @@ class ProjectBuilder { } } - const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects, cacheDir); + const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects); const cleanupSigHooks = this._registerCleanupSigHooks(); let fsTarget; if (destPath) { @@ -274,9 +274,13 @@ class ProjectBuilder { pWrites.push(this._writeResults(projectBuildContext, fsTarget)); } - if (cacheDir && !alreadyBuilt.includes(projectName)) { - this.#log.verbose(`Serializing cache...`); - pWrites.push(projectBuildContext.getBuildCache().serializeToDisk()); + if (!alreadyBuilt.includes(projectName)) { + this.#log.verbose(`Saving cache...`); + const metadata = await createBuildManifest( + project, + this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), + projectBuildContext.getBuildSignature()); + pWrites.push(projectBuildContext.getBuildCache().saveToDisk(metadata)); } } await Promise.all(pWrites); @@ -294,7 +298,7 @@ class ProjectBuilder { return projectBuildContext.getProject(); }); const watchHandler = this._buildContext.initWatchHandler(relevantProjects, async () => { - await this.#update(projectBuildContexts, requestedProjects, fsTarget, cacheDir); + await this.#update(projectBuildContexts, requestedProjects, fsTarget); }); return watchHandler; @@ -315,7 +319,7 @@ class ProjectBuilder { } } - async #update(projectBuildContexts, requestedProjects, fsTarget, cacheDir) { + async #update(projectBuildContexts, requestedProjects, fsTarget) { const queue = []; await this._graph.traverseDepthFirst(async ({project}) => { const projectName = project.getName(); @@ -362,17 +366,14 @@ class ProjectBuilder { pWrites.push(this._writeResults(projectBuildContext, fsTarget)); } - if (cacheDir) { - this.#log.verbose(`Updating cache...`); - // TODO: Only serialize if cache has changed - // TODO: Serialize lazily, or based on memory pressure - pWrites.push(projectBuildContext.getBuildCache().serializeToDisk()); - } + this.#log.verbose(`Updating cache...`); + // TODO: Serialize lazily, or based on memory pressure + pWrites.push(projectBuildContext.getBuildCache().saveToDisk()); } await Promise.all(pWrites); } - async _createRequiredBuildContexts(requestedProjects, cacheDir) { + async _createRequiredBuildContexts(requestedProjects) { const requiredProjects = new Set(this._graph.getProjectNames().filter((projectName) => { return requestedProjects.includes(projectName); })); @@ -382,8 +383,7 @@ class ProjectBuilder { for (const projectName of requiredProjects) { this.#log.verbose(`Creating build context for project ${projectName}...`); const projectBuildContext = await this._buildContext.createProjectContext({ - project: this._graph.getProject(projectName), - cacheDir, + project: this._graph.getProject(projectName) }); projectBuildContexts.set(projectName, projectBuildContext); @@ -488,12 +488,9 @@ class ProjectBuilder { if (createBuildManifest) { // Create and write a build manifest metadata file - const { - default: createBuildManifest - } = await import("./helpers/createBuildManifest.js"); const metadata = await createBuildManifest( project, this._graph, buildConfig, this._buildContext.getTaskRepository(), - projectBuildContext.getBuildCache()); + projectBuildContext.getBuildSignature()); await target.write(resourceFactory.createResource({ path: `/.ui5/build-manifest.json`, string: JSON.stringify(metadata, null, "\t") diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 2dbd7c63686..eb3668a2612 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -16,13 +16,14 @@ class TaskRunner { * @param {object} parameters.graph * @param {object} parameters.project * @param {@ui5/logger/loggers/ProjectBuild} parameters.log Logger to use + * @param {@ui5/project/build/cache/ProjectBuildCache} parameters.buildCache Build cache instance * @param {@ui5/project/build/helpers/TaskUtil} parameters.taskUtil TaskUtil instance * @param {@ui5/builder/tasks/taskRepository} parameters.taskRepository Task repository * @param {@ui5/project/build/ProjectBuilder~BuildConfiguration} parameters.buildConfig * Build configuration */ - constructor({graph, project, log, cache, taskUtil, taskRepository, buildConfig}) { - if (!graph || !project || !log || !cache || !taskUtil || !taskRepository || !buildConfig) { + constructor({graph, project, log, buildCache, taskUtil, taskRepository, buildConfig}) { + if (!graph || !project || !log || !buildCache || !taskUtil || !taskRepository || !buildConfig) { throw new Error("TaskRunner: One or more mandatory parameters not provided"); } this._project = project; @@ -31,7 +32,7 @@ class TaskRunner { this._taskRepository = taskRepository; this._buildConfig = buildConfig; this._log = log; - this._cache = cache; + this._buildCache = buildCache; this._directDependencies = new Set(this._taskUtil.getDependencies()); } @@ -192,38 +193,35 @@ class TaskRunner { options.projectNamespace = this._project.getNamespace(); // TODO: Apply cache and stage handling for custom tasks as well - this._project.useStage(taskName); - - // Check whether any of the relevant resources have changed - if (this._cache.hasCacheForTask(taskName)) { - await this._cache.validateChangedProjectResources( - taskName, this._project.getReader(), this._allDependenciesReader); - if (this._cache.hasValidCacheForTask(taskName)) { - this._log.skipTask(taskName); - return; - } + const requiresRun = await this._buildCache.prepareTaskExecution(taskName, this._allDependenciesReader); + if (!requiresRun) { + this._log.skipTask(taskName); + return; } + + const expectedOutput = new Set(); // TODO: Determine expected output properly + this._log.info( `Executing task ${taskName} for project ${this._project.getName()}`); const workspace = createTracker(this._project.getWorkspace()); const params = { workspace, taskUtil: this._taskUtil, - options, - buildCache: { + cacheUtil: { // TODO: Create a proper interface for this hasCache: () => { - return this._cache.hasCacheForTask(taskName); + return this._buildCache.hasTaskCache(taskName); }, getChangedProjectResourcePaths: () => { - return this._cache.getChangedProjectResourcePaths(taskName); + return this._buildCache.getChangedProjectResourcePaths(taskName); }, getChangedDependencyResourcePaths: () => { - return this._cache.getChangedDependencyResourcePaths(taskName); + return this._buildCache.getChangedDependencyResourcePaths(taskName); }, - } + }, + options, }; - // const invalidatedResources = this._cache.getDepsOfInvalidatedResourcesForTask(taskName); + // const invalidatedResources = this._buildCache.getDepsOfInvalidatedResourcesForTask(taskName); // if (invalidatedResources) { // params.invalidatedResources = invalidatedResources; // } @@ -246,7 +244,7 @@ class TaskRunner { `Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); } this._log.endTask(taskName); - await this._cache.updateTaskResult(taskName, workspace, dependencies); + await this._buildCache.recordTaskResult(taskName, expectedOutput, workspace, dependencies); }; } this._tasks[taskName] = { @@ -319,6 +317,8 @@ class TaskRunner { // Tasks can provide an optional callback to tell build process which dependencies they require const requiredDependenciesCallback = await task.getRequiredDependenciesCallback(); + const getBuildSignatureCallback = await task.getBuildSignatureCallback(); + const getExpectedOutputCallback = await task.getExpectedOutputCallback(); const specVersion = task.getSpecVersion(); let requiredDependencies; @@ -390,6 +390,8 @@ class TaskRunner { taskName: newTaskName, taskConfiguration: taskDef.configuration, provideDependenciesReader, + getBuildSignatureCallback, + getExpectedOutputCallback, getDependenciesReader: () => { // Create the dependencies reader on-demand return this._createDependenciesReader(requiredDependencies); @@ -488,7 +490,7 @@ class TaskRunner { * @returns {Promise} Resolves when task has finished */ async _executeTask(taskName, taskFunction, taskParams) { - if (this._cache.hasValidCacheForTask(taskName)) { + if (this._buildCache.isTaskCacheValid(taskName)) { // Immediately skip task if cache is valid // Continue if cache is (potentially) invalid, in which case taskFunction will // validate the cache thoroughly diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index a0aa46f572a..001cf546c4e 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -1,5 +1,6 @@ import micromatch from "micromatch"; import {getLogger} from "@ui5/logger"; +import {createResourceIndex} from "./utils.js"; const log = getLogger("build:cache:BuildTaskCache"); /** @@ -37,23 +38,23 @@ function unionObject(target, obj) { } } -async function createMetadataForResources(resourceMap) { - const metadata = Object.create(null); - await Promise.all(Object.keys(resourceMap).map(async (resourcePath) => { - const resource = resourceMap[resourcePath]; - if (resource.hash) { - // Metadata object - metadata[resourcePath] = resource; - return; - } - // Resource instance - metadata[resourcePath] = { - hash: await resource.getHash(), - lastModified: resource.getStatInfo()?.mtimeMs, - }; - })); - return metadata; -} +// async function createMetadataForResources(resourceMap) { +// const metadata = Object.create(null); +// await Promise.all(Object.keys(resourceMap).map(async (resourcePath) => { +// const resource = resourceMap[resourcePath]; +// if (resource.hash) { +// // Metadata object +// metadata[resourcePath] = resource; +// return; +// } +// // Resource instance +// metadata[resourcePath] = { +// integrity: await resource.getIntegrity(), +// lastModified: resource.getLastModified(), +// }; +// })); +// return metadata; +// } /** * Manages the build cache for a single task @@ -89,7 +90,7 @@ export default class BuildTaskCache { * @param {string} taskName - Name of the task * @param {TaskCacheMetadata} metadata - Task cache metadata */ - constructor(projectName, taskName, {projectRequests, dependencyRequests, resourcesRead, resourcesWritten}) { + constructor(projectName, taskName, {projectRequests, dependencyRequests, input, output}) { this.#projectName = projectName; this.#taskName = taskName; @@ -102,8 +103,8 @@ export default class BuildTaskCache { pathsRead: [], patterns: [], }; - this.#resourcesRead = resourcesRead ?? Object.create(null); - this.#resourcesWritten = resourcesWritten ?? Object.create(null); + this.#resourcesRead = input ?? Object.create(null); + this.#resourcesWritten = output ?? Object.create(null); } // ===== METADATA ACCESS ===== @@ -122,8 +123,8 @@ export default class BuildTaskCache { * * @param {RequestMetadata} projectRequests - Project resource requests * @param {RequestMetadata} [dependencyRequests] - Dependency resource requests - * @param {Object.} resourcesRead - Resources read by task - * @param {Object.} resourcesWritten - Resources written by task + * @param {Object} resourcesRead - Resources read by task + * @param {Object} resourcesWritten - Resources written by task * @returns {void} */ updateMetadata(projectRequests, dependencyRequests, resourcesRead, resourcesWritten) { @@ -144,15 +145,12 @@ export default class BuildTaskCache { * * @returns {Promise} Serialized task cache data */ - async toJSON() { + async createMetadata() { return { - taskName: this.#taskName, - resourceMetadata: { - projectRequests: this.#projectRequests, - dependencyRequests: this.#dependencyRequests, - resourcesRead: await createMetadataForResources(this.#resourcesRead), - resourcesWritten: await createMetadataForResources(this.#resourcesWritten) - } + projectRequests: this.#projectRequests, + dependencyRequests: this.#dependencyRequests, + taskIndex: await createResourceIndex(Object.values(this.#resourcesRead)), + // resourcesWritten: await createMetadataForResources(this.#resourcesWritten) }; } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 138b0d8d373..0682c6ac75f 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,16 +1,65 @@ import cacache from "cacache"; +import path from "node:path"; +import fs from "graceful-fs"; +import {promisify} from "node:util"; +const mkdir = promisify(fs.mkdir); +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); +import os from "node:os"; +import Configuration from "../../config/Configuration.js"; +import {getPathFromPackageName} from "../../utils/sanitizeFileName.js"; +import {getLogger} from "@ui5/logger"; -export class CacheManager { +const log = getLogger("project:build:cache:CacheManager"); + +const chacheManagerInstances = new Map(); +const CACACHE_OPTIONS = {algorithms: ["sha256"]}; + +/** + * Persistence management for the build cache. Using a file-based index and cacache + * + * cacheDir structure: + * - cas/ -- cacache content addressable storage + * - buildManifests/ -- build manifest files (acting as index, internally referencing cacache entries) + * + */ +export default class CacheManager { constructor(cacheDir) { this._cacheDir = cacheDir; } - async get(cacheKey) { + static async create(cwd) { + // ENV var should take precedence over the dataDir from the configuration. + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + ui5DataDir = path.resolve(cwd, ui5DataDir); + } else { + ui5DataDir = path.join(os.homedir(), ".ui5"); + } + const cacheDir = path.join(ui5DataDir, "buildCache"); + log.verbose(`Using build cache directory: ${cacheDir}`); + + if (!chacheManagerInstances.has(cacheDir)) { + chacheManagerInstances.set(cacheDir, new CacheManager(cacheDir)); + } + return chacheManagerInstances.get(cacheDir); + } + + #getBuildManifestPath(packageName, buildSignature) { + const pkgDir = getPathFromPackageName(packageName); + return path.join(this._cacheDir, pkgDir, `${buildSignature}.json`); + } + + async readBuildManifest(project, buildSignature) { try { - const result = await cacache.get(this._cacheDir, cacheKey); - return JSON.parse(result.data.toString("utf-8")); + const manifest = await readFile(this.#getBuildManifestPath(project.getId(), buildSignature), "utf8"); + return JSON.parse(manifest); } catch (err) { - if (err.code === "ENOENT" || err.code === "EINTEGRITY") { + if (err.code === "ENOENT") { // Cache miss return null; } @@ -18,11 +67,71 @@ export class CacheManager { } } - async put(cacheKey, data) { - await cacache.put(this._cacheDir, cacheKey, data); + async writeBuildManifest(project, buildSignature, manifest) { + const manifestPath = this.#getBuildManifestPath(project.getId(), buildSignature); + await mkdir(path.dirname(manifestPath), {recursive: true}); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + } + + async getResourcePathForStage(buildSignature, stageName, resourcePath, integrity) { + // try { + if (!integrity) { + throw new Error("Integrity hash must be provided to read from cache"); + } + const cacheKey = this.#createKeyForStage(buildSignature, stageName, resourcePath); + const result = await cacache.get.info(this._cacheDir, cacheKey); + if (result.integrity !== integrity) { + log.info(`Integrity mismatch for cache entry ` + + `${cacheKey}: expected ${integrity}, got ${result.integrity}`); + + const res = await cacache.get.byDigest(this._cacheDir, result.integrity); + if (res) { + log.info(`Updating cache entry with expectation...`); + await this.writeStage(buildSignature, stageName, resourcePath, res.data); + return await this.getResourcePathForStage(buildSignature, stageName, resourcePath, integrity); + } + } + if (!result) { + return null; + } + return result.path; + // } catch (err) { + // if (err.code === "ENOENT") { + // // Cache miss + // return null; + // } + // throw err; + // } + } + + async writeStage(buildSignature, stageName, resourcePath, buffer) { + return await cacache.put( + this._cacheDir, + this.#createKeyForStage(buildSignature, stageName, resourcePath), + buffer, + CACACHE_OPTIONS + ); + } + + async writeStageStream(buildSignature, stageName, resourcePath, stream) { + const writable = cacache.put.stream( + this._cacheDir, + this.#createKeyForStage(buildSignature, stageName, resourcePath), + stream, + CACACHE_OPTIONS, + ); + return new Promise((resolve, reject) => { + writable.on("integrity", (digest) => { + resolve(digest); + }); + writable.on("error", (err) => { + reject(err); + }); + stream.pipe(writable); + }); } - async putStream(cacheKey, stream) { - await cacache.put.stream(this._cacheDir, cacheKey, stream); + #createKeyForStage(buildSignature, stageName, resourcePath) { + return `${buildSignature}|${stageName}|${resourcePath}`; } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 4d8fe8c2ee0..1a3df9f9cd2 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -1,55 +1,45 @@ -import path from "node:path"; -import {stat} from "node:fs/promises"; -import {createResource, createAdapter} from "@ui5/fs/resourceFactory"; +import {createResource, createProxy} from "@ui5/fs/resourceFactory"; import {getLogger} from "@ui5/logger"; +import fs from "graceful-fs"; +import {promisify} from "node:util"; +const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; +import {createResourceIndex} from "./utils.js"; const log = getLogger("build:cache:ProjectBuildCache"); -/** - * A project's build cache can have multiple states - * - Initial build without existing build manifest or cache: - * * No build manifest - * * Tasks are unknown - * * Resources are unknown - * * No persistence of workspaces - * - Build of project with build manifest - * * (a valid build manifest implies that the project will not be built initially) - * * Tasks are known - * * Resources required and produced by tasks are known - * * No persistence of workspaces - * * => In case of a rebuild, all tasks need to be executed once to restore the workspaces - * - Build of project with build manifest and cache - * * Tasks are known - * * Resources required and produced by tasks are known - * * Workspaces can be restored from cache - */ - export default class ProjectBuildCache { #taskCache = new Map(); #project; - #cacheKey; - #cacheDir; - #cacheRoot; + #buildSignature; + #cacheManager; + // #cacheDir; #invalidatedTasks = new Map(); #updatedResources = new Set(); - #restoreFailed = false; /** * Creates a new ProjectBuildCache instance * - * @param {object} project - Project instance - * @param {string} cacheKey - Cache key identifying this build configuration - * @param {string} [cacheDir] - Optional cache directory for persistence + * @param {object} project Project instance + * @param {string} buildSignature Build signature for the current build + * @param {CacheManager} cacheManager Cache manager instance + * + * @private - Use ProjectBuildCache.create() instead */ - constructor(project, cacheKey, cacheDir) { + constructor(project, buildSignature, cacheManager) { this.#project = project; - this.#cacheKey = cacheKey; - this.#cacheDir = cacheDir; - this.#cacheRoot = cacheDir && createAdapter({ - fsBasePath: cacheDir, - virBasePath: "/" - }); + this.#buildSignature = buildSignature; + this.#cacheManager = cacheManager; + // this.#cacheRoot = cacheDir && createAdapter({ + // fsBasePath: cacheDir, + // virBasePath: "/" + // }); + } + + static async create(project, buildSignature, cacheManager) { + const cache = new ProjectBuildCache(project, buildSignature, cacheManager); + await cache.#attemptLoadFromDisk(); + return cache; } // ===== TASK MANAGEMENT ===== @@ -62,12 +52,13 @@ export default class ProjectBuildCache { * 2. Detects which resources have actually changed * 3. Invalidates downstream tasks if necessary * - * @param {string} taskName - Name of the executed task - * @param {object} workspaceTracker - Tracker that monitored workspace reads - * @param {object} [dependencyTracker] - Tracker that monitored dependency reads + * @param {string} taskName Name of the executed task + * @param {Set|undefined} expectedOutput Expected output resource paths + * @param {object} workspaceTracker Tracker that monitored workspace reads + * @param {object} [dependencyTracker] Tracker that monitored dependency reads * @returns {Promise} */ - async recordTaskResult(taskName, workspaceTracker, dependencyTracker) { + async recordTaskResult(taskName, expectedOutput, workspaceTracker, dependencyTracker) { const projectTrackingResults = workspaceTracker.getResults(); const dependencyTrackingResults = dependencyTracker?.getResults(); @@ -109,9 +100,9 @@ export default class ProjectBuildCache { } // Check whether other tasks need to be invalidated const allTasks = Array.from(this.#taskCache.keys()); - const taskIndex = allTasks.indexOf(taskName); + const taskIdx = allTasks.indexOf(taskName); const emptySet = new Set(); - for (let i = taskIndex + 1; i < allTasks.length; i++) { + for (let i = taskIdx + 1; i < allTasks.length; i++) { const nextTaskName = allTasks[i]; if (!this.#taskCache.get(nextTaskName).matchesChangedResources(changedPaths, emptySet)) { continue; @@ -258,7 +249,7 @@ export default class ProjectBuildCache { * @param {string} taskName - Name of the task * @returns {Set} Set of changed project resource paths */ - getChangedProjectPaths(taskName) { + getChangedProjectResourcePaths(taskName) { return this.#invalidatedTasks.get(taskName)?.changedProjectResourcePaths ?? new Set(); } @@ -268,7 +259,7 @@ export default class ProjectBuildCache { * @param {string} taskName - Name of the task * @returns {Set} Set of changed dependency resource paths */ - getChangedDependencyPaths(taskName) { + getChangedDependencyResourcePaths(taskName) { return this.#invalidatedTasks.get(taskName)?.changedDependencyResourcePaths ?? new Set(); } @@ -314,22 +305,39 @@ export default class ProjectBuildCache { return !this.hasAnyCache() || this.#invalidatedTasks.size > 0; } - /** - * Gets the current status of the cache for debugging and monitoring - * - * @returns {object} Status information including cache state and statistics - */ - getStatus() { - return { - hasCache: this.hasAnyCache(), - totalTasks: this.#taskCache.size, - invalidatedTasks: this.#invalidatedTasks.size, - modifiedResourceCount: this.#updatedResources.size, - cacheKey: this.#cacheKey, - restoreFailed: this.#restoreFailed - }; + async prepareTaskExecution(taskName, dependencyReader) { + // Check cache exists and ensure it's still valid before using it + if (this.hasTaskCache(taskName)) { + // Check whether any of the relevant resources have changed + await this.validateChangedResources(taskName, this.#project.getReader(), dependencyReader); + + if (this.isTaskCacheValid(taskName)) { + return false; // No need to execute task, cache is valid + } + } + + // Switch project to use cached stage as base layer + const stageName = this.#getStageNameForTask(taskName); + this.#project.useStage(stageName); + return true; // Task needs to be executed } + // /** + // * Gets the current status of the cache for debugging and monitoring + // * + // * @returns {object} Status information including cache state and statistics + // */ + // getStatus() { + // return { + // hasCache: this.hasAnyCache(), + // totalTasks: this.#taskCache.size, + // invalidatedTasks: this.#invalidatedTasks.size, + // modifiedResourceCount: this.#updatedResources.size, + // buildSignature: this.#buildSignature, + // restoreFailed: this.#restoreFailed + // }; + // } + /** * Gets the names of all invalidated tasks * @@ -339,66 +347,126 @@ export default class ProjectBuildCache { return Array.from(this.#invalidatedTasks.keys()); } - async toObject() { - // const globalResourceIndex = Object.create(null); - // function addResourcesToIndex(taskName, resourceMap) { - // for (const resourcePath of Object.keys(resourceMap)) { - // const resource = resourceMap[resourcePath]; - // const resourceKey = `${resourcePath}:${resource.hash}`; - // if (!globalResourceIndex[resourceKey]) { - // globalResourceIndex[resourceKey] = { - // hash: resource.hash, - // lastModified: resource.lastModified, - // tasks: [taskName] - // }; - // } else if (!globalResourceIndex[resourceKey].tasks.includes(taskName)) { - // globalResourceIndex[resourceKey].tasks.push(taskName); - // } - // } - // } - const taskCache = []; - for (const cache of this.#taskCache.values()) { - const cacheObject = await cache.toJSON(); - taskCache.push(cacheObject); - // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesRead); - // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesWritten); - // addResourcesToIndex(taskName, cacheObject.resources.dependencies.resourcesRead); + // async createBuildManifest() { + // // const globalResourceIndex = Object.create(null); + // // function addResourcesToIndex(taskName, resourceMap) { + // // for (const resourcePath of Object.keys(resourceMap)) { + // // const resource = resourceMap[resourcePath]; + // // const resourceKey = `${resourcePath}:${resource.hash}`; + // // if (!globalResourceIndex[resourceKey]) { + // // globalResourceIndex[resourceKey] = { + // // hash: resource.hash, + // // lastModified: resource.lastModified, + // // tasks: [taskName] + // // }; + // // } else if (!globalResourceIndex[resourceKey].tasks.includes(taskName)) { + // // globalResourceIndex[resourceKey].tasks.push(taskName); + // // } + // // } + // // } + // const taskCache = []; + // for (const cache of this.#taskCache.values()) { + // const cacheObject = await cache.toJSON(); + // taskCache.push(cacheObject); + // // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesRead); + // // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesWritten); + // // addResourcesToIndex(taskName, cacheObject.resources.dependencies.resourcesRead); + // } + // // Collect metadata for all relevant source files + // const sourceReader = this.#project.getSourceReader(); + // // const resourceMetadata = await Promise.all(Array.from(relevantSourceFiles).map(async (resourcePath) => { + // const resources = await sourceReader.byGlob("/**/*"); + // const sourceMetadata = Object.create(null); + // await Promise.all(resources.map(async (resource) => { + // sourceMetadata[resource.getOriginalPath()] = { + // lastModified: resource.getStatInfo()?.mtimeMs, + // hash: await resource.getHash(), + // }; + // })); + + // return { + // timestamp: Date.now(), + // cacheKey: this.#cacheKey, + // taskCache, + // sourceMetadata, + // // globalResourceIndex, + // }; + // } + + async #createCacheManifest() { + const cache = Object.create(null); + cache.index = await this.#createIndex(this.#project.getSourceReader(), true); + cache.indexTimestamp = Date.now(); // TODO: This is way too late if the resource' metadata has been cached + + cache.taskMetadata = Object.create(null); + for (const [taskName, taskCache] of this.#taskCache) { + cache.taskMetadata[taskName] = await taskCache.createMetadata(); } - // Collect metadata for all relevant source files - const sourceReader = this.#project.getSourceReader(); - // const resourceMetadata = await Promise.all(Array.from(relevantSourceFiles).map(async (resourcePath) => { - const resources = await sourceReader.byGlob("/**/*"); - const sourceMetadata = Object.create(null); - await Promise.all(resources.map(async (resource) => { - sourceMetadata[resource.getOriginalPath()] = { - lastModified: resource.getStatInfo()?.mtimeMs, - hash: await resource.getHash(), - }; - })); - return { - timestamp: Date.now(), - cacheKey: this.#cacheKey, - taskCache, - sourceMetadata, - // globalResourceIndex, - }; + cache.stages = Object.create(null); + + // const stages = this.#project.getStages(); + return cache; } - async #serializeMetadata() { - const serializedCache = await this.toJSON(); - const cacheContent = JSON.stringify(serializedCache, null, 2); - const res = createResource({ - path: `/cache-info.json`, - string: cacheContent, - }); - await this.#cacheRoot.write(res); + async #createIndex(reader, includeInode = false) { + const resources = await reader.byGlob("/**/*"); + return await createResourceIndex(resources, includeInode); + } + + async #saveBuildManifest(buildManifest) { + buildManifest.cache = await this.#createCacheManifest(); + + await this.#cacheManager.writeBuildManifest( + this.#project, this.#buildSignature, buildManifest); + + // const serializedCache = await this.toJSON(); + // const cacheContent = JSON.stringify(serializedCache, null, 2); + // const res = createResource({ + // path: `/cache-info.json`, + // string: cacheContent, + // }); + // await this.#cacheRoot.write(res); + } + + // async #serializeTaskOutputs() { + // log.info(`Serializing task outputs for project ${this.#project.getName()}`); + // const stageCache = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { + // const reader = this.#project.getDeltaReader(taskName); + // if (!reader) { + // log.verbose( + // `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` + // ); + // return; + // } + // const resources = await reader.byGlob("/**/*"); + + // const target = createAdapter({ + // fsBasePath: path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`), + // virBasePath: "/" + // }); + + // for (const res of resources) { + // await target.write(res); + // } + // return { + // reader: target, + // stage: taskName + // }; + // })); + // // Re-import cache as base layer to reduce memory pressure + // this.#project.importCachedStages(stageCache.filter((entry) => entry)); + // } + + async #getStageNameForTask(taskName) { + return `tasks/${taskName}`; } - async #serializeTaskOutputs() { - log.info(`Serializing task outputs for project ${this.#project.getName()}`); + async #saveCachedStages() { + log.info(`Storing task outputs for project ${this.#project.getName()} in cache...`); const stageCache = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { - const reader = this.#project.getDeltaReader(taskName); + const stageName = this.#getStageNameForTask(taskName); + const reader = this.#project.getDeltaReader(stageName); if (!reader) { log.verbose( `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` @@ -407,13 +475,16 @@ export default class ProjectBuildCache { } const resources = await reader.byGlob("/**/*"); - const target = createAdapter({ - fsBasePath: path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`), - virBasePath: "/" - }); - for (const res of resources) { - await target.write(res); + // Store resource content in cacache via CacheManager + const integrity = await this.#cacheManager.writeStageStream( + this.#buildSignature, stageName, + res.getOriginalPath(), await res.getStreamAsync() + ); + // const integrity = await this.#cacheManager.writeStage( + // this.#buildSignature, stageName, + // res.getOriginalPath(), await res.getBuffer() + // ); } return { reader: target, @@ -424,34 +495,58 @@ export default class ProjectBuildCache { this.#project.importCachedStages(stageCache.filter((entry) => entry)); } - async #checkSourceChanges(sourceMetadata) { + async #checkForIndexChanges(index, indexTimestamp) { log.verbose(`Checking for source changes for project ${this.#project.getName()}`); const sourceReader = this.#project.getSourceReader(); const resources = await sourceReader.byGlob("/**/*"); const changedResources = new Set(); for (const resource of resources) { + const currentLastModified = resource.getLastModified(); + if (currentLastModified > indexTimestamp) { + // Resource modified after index was created, no need for further checks + log.verbose(`Source file created or modified after index creation: ${resourcePath}`); + changedResources.add(resourcePath); + continue; + } + // Check against index const resourcePath = resource.getOriginalPath(); - const resourceMetadata = sourceMetadata[resourcePath]; - if (!resourceMetadata) { - // New resource - log.verbose(`New resource: ${resourcePath}`); + if (!index.hasOwnProperty(resourcePath)) { + // New resource encountered + log.verbose(`New source file: ${resourcePath}`); + changedResources.add(resourcePath); + continue; + } + const {lastModified, size, inode, integrity} = index[resourcePath]; + + if (resourceMetadata.lastModified !== currentLastModified) { + log.verbose(`Source file modified: ${resourcePath} (timestamp change)`); changedResources.add(resourcePath); continue; } - if (resourceMetadata.lastModified !== resource.getStatInfo()?.mtimeMs) { - log.verbose(`Resource changed: ${resourcePath}`); + + if (resourceMetadata.inode !== resource.getInode()) { + log.verbose(`Source file modified: ${resourcePath} (inode change)`); changedResources.add(resourcePath); + continue; } - // TODO: Hash-based check can be requested by user and per project - // The performance impact can be quite high for large projects - /* - if (someFlag) { - const currentHash = await resource.getHash(); - if (currentHash !== resourceMetadata.hash) { - log.verbose(`Resource changed: ${resourcePath}`); + + if (resourceMetadata.size !== await resource.getSize()) { + log.verbose(`Source file modified: ${resourcePath} (size change)`); + changedResources.add(resourcePath); + continue; + } + + if (currentLastModified === indexTimestamp) { + // If the source modification time is equal to index creation time, + // it's possible for a race condition to have occurred where the file was modified + // during index creation without changing its size. + // In this case, we need to perform an integrity check to determine if the file has changed. + const currentIntegrity = await resource.getIntegrity(); + if (currentIntegrity !== integrity) { + log.verbose(`Resource changed: ${resourcePath} (integrity change)`); changedResources.add(resourcePath); } - }*/ + } } if (changedResources.size) { const invalidatedTasks = this.markResourcesChanged(changedResources, new Set()); @@ -461,41 +556,82 @@ export default class ProjectBuildCache { } } - async #deserializeWriter() { - const cachedStages = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { - const fsBasePath = path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`); - let cacheReader; - if (await exists(fsBasePath)) { - cacheReader = createAdapter({ - name: `Cache reader for task ${taskName} in project ${this.#project.getName()}`, - fsBasePath, - virBasePath: "/", - project: this.#project, + async #createReaderForStageCache(stageName, resourceMetadata) { + const allResourcePaths = Object.keys(resourceMetadata); + return createProxy({ + name: `Cache reader for task ${stageName} in project ${this.#project.getName()}`, + listResourcePaths: () => { + return allResourcePaths; + }, + getResource: async (virPath) => { + if (!allResourcePaths.includes(virPath)) { + return null; + } + const {lastModified, size, integrity} = resourceMetadata[virPath]; + if (size === undefined || lastModified === undefined || + integrity === undefined) { + throw new Error(`Incomplete metadata for resource ${virPath} of task ${stageName} ` + + `in project ${this.#project.getName()}`); + } + // Get path to cached file contend stored in cacache via CacheManager + const cachePath = await this.#cacheManager.getPathForTaskResource( + this.#buildSignature, stageName, virPath, integrity); + if (!cachePath) { + log.warn(`Content of resource ${virPath} of task ${stageName} ` + + `in project ${this.#project.getName()}`); + return null; + } + return createResource({ + path: virPath, + sourceMetadata: { + fsPath: cachePath + }, + createStream: () => { + return fs.createReadStream(cachePath); + }, + createBuffer: async () => { + return await readFile(cachePath); + }, + size, + lastModified, + integrity, }); } + }); + } + async #importCachedStages(stages) { + // const cachedStages = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { + // // const fsBasePath = path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`); + // // let cacheReader; + // // if (await exists(fsBasePath)) { + // // cacheReader = createAdapter({ + // // name: `Cache reader for task ${taskName} in project ${this.#project.getName()}`, + // // fsBasePath, + // // virBasePath: "/", + // // project: this.#project, + // // }); + // // } + + // return { + // stage: taskName, + // reader: cacheReader + // }; + // })); + const cachedStages = await Promise.all(Object.entries(stages).map(async ([stageName, resourceMetadata]) => { + const reader = await this.#createReaderForStageCache(stageName, resourceMetadata); return { - stage: taskName, - reader: cacheReader + stageName, + reader }; })); this.#project.importCachedStages(cachedStages); } - /** - * Saves the cache to disk - * - * @returns {Promise} - * @throws {Error} If cache persistence is not available - */ - async saveToDisk() { - if (!this.#cacheRoot) { - log.error("Cannot save cache to disk: No cache persistence available"); - return; - } + async saveToDisk(buildManifest) { await Promise.all([ - await this.#serializeTaskOutputs(), - await this.#serializeMetadata() + await this.#saveCachedStages(), + await this.#saveBuildManifest(buildManifest) ]); } @@ -508,25 +644,42 @@ export default class ProjectBuildCache { * @returns {Promise} * @throws {Error} If cache restoration fails */ - async loadFromDisk() { - if (this.#restoreFailed || !this.#cacheRoot) { + async #attemptLoadFromDisk() { + const manifest = await this.#cacheManager.readBuildManifest(this.#project, this.#buildSignature); + if (!manifest) { + log.verbose(`No build manifest found for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); return; } - const res = await this.#cacheRoot.byPath(`/cache-info.json`); - if (!res) { - this.#restoreFailed = true; - return; - } - const cacheContent = JSON.parse(await res.getString()); + try { - const projectName = this.#project.getName(); - for (const {taskName, resourceMetadata} of cacheContent.taskCache) { - this.#taskCache.set(taskName, new BuildTaskCache(projectName, taskName, resourceMetadata)); + // Check build manifest version + if (manifest.version !== "1.0") { + log.verbose(`Incompatible build manifest version ${manifest.version} found for project ` + + `${this.#project.getName()} with build signature ${this.#buildSignature}. Ignoring cache.`); + return; + } + // TODO: Validate manifest against a schema + + // Validate build signature match + if (this.#buildSignature !== manifest.buildManifest.signature) { + throw new Error( + `Build manifest signature ${manifest.buildManifest.signature} does not match expected ` + + `build signature ${this.#buildSignature} for project ${this.#project.getName()}`); + } + log.info( + `Restoring build cache for project ${this.#project.getName()} from build manifest ` + + `with signature ${this.#buildSignature}`); + + const {cache} = manifest; + for (const [taskName, metadata] of Object.entries(cache.tasksMetadata)) { + this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, metadata)); } await Promise.all([ - this.#checkSourceChanges(cacheContent.sourceMetadata), - this.#deserializeWriter() + this.#checkForIndexChanges(cache.index, cache.indexTimestamp), + this.#importCachedStages(cache.stages), ]); + // this.#buildManifest = manifest; } catch (err) { throw new Error( `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { @@ -536,16 +689,16 @@ export default class ProjectBuildCache { } } -async function exists(filePath) { - try { - await stat(filePath); - return true; - } catch (err) { - // "File or directory does not exist" - if (err.code === "ENOENT") { - return false; - } else { - throw err; - } - } -} +// async function exists(filePath) { +// try { +// await stat(filePath); +// return true; +// } catch (err) { +// // "File or directory does not exist" +// if (err.code === "ENOENT") { +// return false; +// } else { +// throw err; +// } +// } +// } diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js new file mode 100644 index 00000000000..387d69aeada --- /dev/null +++ b/packages/project/lib/build/cache/utils.js @@ -0,0 +1,16 @@ +export async function createResourceIndex(resources, includeInode = false) { + const index = Object.create(null); + await Promise.all(resources.map(async (resource) => { + const resourceMetadata = { + lastModified: resource.getLastModified(), + size: await resource.getSize(), + integrity: await resource.getIntegrity(), + }; + if (includeInode) { + resourceMetadata.inode = resource.getInode(); + } + + index[resource.getOriginalPath()] = resourceMetadata; + })); + return index; +} diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 063aaf30e21..bab2e2f0282 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -1,8 +1,7 @@ -import path from "node:path"; import ProjectBuildContext from "./ProjectBuildContext.js"; import OutputStyleEnum from "./ProjectBuilderOutputStyle.js"; -import {createCacheKey} from "./createBuildManifest.js"; import WatchHandler from "./WatchHandler.js"; +import CacheManager from "../cache/CacheManager.js"; /** * Context of a build process @@ -12,6 +11,7 @@ import WatchHandler from "./WatchHandler.js"; */ class BuildContext { #watchHandler; + #cacheManager; constructor(graph, taskRepository, { // buildConfig selfContained = false, @@ -104,17 +104,8 @@ class BuildContext { return this._graph; } - async createProjectContext({project, cacheDir}) { - const cacheKey = await this.#createCacheKeyForProject(project); - if (cacheDir) { - cacheDir = path.join(cacheDir, cacheKey); - } - const projectBuildContext = new ProjectBuildContext({ - buildContext: this, - project, - cacheKey, - cacheDir, - }); + async createProjectContext({project}) { + const projectBuildContext = await ProjectBuildContext.create(this, project); this._projectBuildContexts.push(projectBuildContext); return projectBuildContext; } @@ -130,9 +121,12 @@ class BuildContext { return this.#watchHandler; } - async #createCacheKeyForProject(project) { - return createCacheKey(project, this._graph, - this.getBuildConfig(), this.getTaskRepository()); + async getCacheManager() { + if (this.#cacheManager) { + return this.#cacheManager; + } + this.#cacheManager = await CacheManager.create(this._graph.getRoot().getRootPath()); + return this.#cacheManager; } getBuildContext(projectName) { diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 20a9e668150..375c95d59b2 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -2,6 +2,7 @@ import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; import ProjectBuildLogger from "@ui5/logger/internal/loggers/ProjectBuild"; import TaskUtil from "./TaskUtil.js"; import TaskRunner from "../TaskRunner.js"; +import calculateBuildSignature from "./calculateBuildSignature.js"; import ProjectBuildCache from "../cache/ProjectBuildCache.js"; /** @@ -14,14 +15,13 @@ import ProjectBuildCache from "../cache/ProjectBuildCache.js"; class ProjectBuildContext { /** * - * @param {object} parameters Parameters - * @param {object} parameters.buildContext The build context. - * @param {object} parameters.project The project instance. - * @param {string} parameters.cacheKey The cache key. - * @param {string} parameters.cacheDir The cache directory. + * @param {object} buildContext The build context. + * @param {object} project The project instance. + * @param {string} buildSignature The signature of the build. + * @param {ProjectBuildCache} buildCache * @throws {Error} Throws an error if 'buildContext' or 'project' is missing. */ - constructor({buildContext, project, cacheKey, cacheDir}) { + constructor(buildContext, project, buildSignature, buildCache) { if (!buildContext) { throw new Error(`Missing parameter 'buildContext'`); } @@ -35,8 +35,8 @@ class ProjectBuildContext { projectName: project.getName(), projectType: project.getType() }); - this._cacheKey = cacheKey; - this._cache = new ProjectBuildCache(this._project, cacheKey, cacheDir); + this._buildSignature = buildSignature; + this._buildCache = buildCache; this._queues = { cleanup: [] }; @@ -45,12 +45,26 @@ class ProjectBuildContext { allowedTags: ["ui5:OmitFromBuildResult", "ui5:IsBundle"], allowedNamespaces: ["build"] }); - const buildManifest = this.#getBuildManifest(); - if (buildManifest) { - this._cache.deserialize(buildManifest.buildManifest.cache); - } + // const buildManifest = this.#getBuildManifest(); + // if (buildManifest) { + // this._buildCache.deserialize(buildManifest.buildManifest.cache); + // } + } + + static async create(buildContext, project) { + const buildSignature = await calculateBuildSignature(project, buildContext.getGraph(), + buildContext.getBuildConfig(), buildContext.getTaskRepository()); + const buildCache = await ProjectBuildCache.create( + project, buildSignature, await buildContext.getCacheManager()); + return new ProjectBuildContext( + buildContext, + project, + buildSignature, + buildCache + ); } + isRootProject() { return this._project === this._buildContext.getRootProject(); } @@ -127,7 +141,7 @@ class ProjectBuildContext { this._taskRunner = new TaskRunner({ project: this._project, log: this._log, - cache: this._cache, + buildCache: this._buildCache, taskUtil: this.getTaskUtil(), graph: this._buildContext.getGraph(), taskRepository: this._buildContext.getTaskRepository(), @@ -148,16 +162,16 @@ class ProjectBuildContext { return false; } - if (!this._cache.hasCache()) { - await this._cache.attemptDeserializationFromDisk(); - } + // if (!this._buildCache.hasAnyCache()) { + // await this._buildCache.attemptDeserializationFromDisk(); + // } - return this._cache.requiresBuild(); + return this._buildCache.needsRebuild(); } async runTasks() { await this.getTaskRunner().runTasks(); - const updatedResourcePaths = this._cache.harvestUpdatedResources(); + const updatedResourcePaths = this._buildCache.collectAndClearModifiedPaths(); if (updatedResourcePaths.size === 0) { return; @@ -170,7 +184,7 @@ class ProjectBuildContext { // Propagate changes to all dependents of the project for (const {project: dep} of graph.traverseDependents(this._project.getName())) { const projectBuildContext = this._buildContext.getBuildContext(dep.getName()); - projectBuildContext.getBuildCache().resourceChanged(emptySet, updatedResourcePaths); + projectBuildContext.getBuildCache().this.markResourcesChanged(emptySet, updatedResourcePaths); } } @@ -184,11 +198,11 @@ class ProjectBuildContext { // Manifest version 0.1 and 0.2 are always used without further checks for legacy reasons return manifest; } - if (manifest.buildManifest.manifestVersion === "0.3" && - manifest.buildManifest.cacheKey === this.getCacheKey()) { - // Manifest version 0.3 is used with a matching cache key - return manifest; - } + // if (manifest.buildManifest.manifestVersion === "0.3" && + // manifest.buildManifest.cacheKey === this.getCacheKey()) { + // // Manifest version 0.3 is used with a matching cache key + // return manifest; + // } // Unknown manifest version can't be used return; } @@ -208,11 +222,11 @@ class ProjectBuildContext { } getBuildCache() { - return this._cache; + return this._buildCache; } - getCacheKey() { - return this._cacheKey; + getBuildSignature() { + return this._buildSignature; } // async watchFileChanges() { @@ -229,7 +243,7 @@ class ProjectBuildContext { // // const resourcePath = this._project.getVirtualPath(filePath); // // this._log.info(`File changed: ${resourcePath} (${filePath})`); // // // Inform cache - // // this._cache.fileChanged(resourcePath); + // // this._buildCache.fileChanged(resourcePath); // // // Inform dependents // // for (const dependent of this._buildContext.getGraph().getTransitiveDependents(this._project.getName())) { // // await this._buildContext.getProjectBuildContext(dependent).dependencyFileChanged(resourcePath); @@ -241,7 +255,7 @@ class ProjectBuildContext { // dependencyFileChanged(resourcePath) { // this._log.info(`Dependency file changed: ${resourcePath}`); - // this._cache.fileChanged(resourcePath); + // this._buildCache.fileChanged(resourcePath); // } } diff --git a/packages/project/lib/build/helpers/calculateBuildSignature.js b/packages/project/lib/build/helpers/calculateBuildSignature.js new file mode 100644 index 00000000000..620c3523715 --- /dev/null +++ b/packages/project/lib/build/helpers/calculateBuildSignature.js @@ -0,0 +1,72 @@ +import {createRequire} from "node:module"; +import crypto from "node:crypto"; + +// Using CommonsJS require since JSON module imports are still experimental +const require = createRequire(import.meta.url); + +/** + * The build signature is calculated based on the **build configuration and environment** of a project. + * + * The hash is represented as a hexadecimal string to allow safe usage in file names. + * + * @private + * @param {@ui5/project/lib/Project} project The project to create the cache integrity for + * @param {@ui5/project/lib/graph/ProjectGraph} graph The project graph + * @param {object} buildConfig The build configuration + * @param {@ui5/builder/tasks/taskRepository} taskRepository The task repository (used to determine the effective + * versions of ui5-builder and ui5-fs) + */ +export default async function calculateBuildSignature(project, graph, buildConfig, taskRepository) { + const depInfo = collectDepInfo(graph, project); + const lockfileHash = await getLockfileHash(project); + const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); + const projectVersion = await getVersion("@ui5/project"); + const fsVersion = await getVersion("@ui5/fs"); + + const key = project.getName() + project.getVersion() + + JSON.stringify(buildConfig) + JSON.stringify(depInfo) + + builderVersion + projectVersion + fsVersion + builderFsVersion + + lockfileHash; + + // Create a hash for all metadata + const hash = crypto.createHash("sha256").update(key).digest("hex"); + return hash; +} + +async function getVersion(pkg) { + return require(`${pkg}/package.json`).version; +} + +async function getLockfileHash(project) { + const rootReader = project.getRootReader({useGitIgnore: false}); + const lockfiles = await Promise.all([ + await rootReader.byPath("/package-lock.json"), + await rootReader.byPath("/yarn.lock"), + await rootReader.byPath("/pnpm-lock.yaml"), + ]); + let hash = ""; + for (const lockfile of lockfiles) { + if (lockfile) { + const content = await lockfile.getBuffer(); + hash += crypto.createHash("sha256").update(content).digest("hex"); + } + } + return hash; +} + +function collectDepInfo(graph, project) { + const projects = Object.create(null); + for (const depName of graph.getTransitiveDependencies(project.getName())) { + const dep = graph.getProject(depName); + projects[depName] = { + version: dep.getVersion() + }; + } + const extensions = Object.create(null); + for (const extension of graph.getExtensions()) { + extensions[extension.getName()] = { + version: extension.getVersion() + }; + } + return {projects, extensions}; +} diff --git a/packages/project/lib/build/helpers/createBuildManifest.js b/packages/project/lib/build/helpers/createBuildManifest.js index ba19023d54f..1a80bae840c 100644 --- a/packages/project/lib/build/helpers/createBuildManifest.js +++ b/packages/project/lib/build/helpers/createBuildManifest.js @@ -1,5 +1,4 @@ import {createRequire} from "node:module"; -import crypto from "node:crypto"; // Using CommonsJS require since JSON module imports are still experimental const require = createRequire(import.meta.url); @@ -17,18 +16,7 @@ function getSortedTags(project) { return Object.fromEntries(entities); } -async function collectDepInfo(graph, project) { - const transitiveDependencyInfo = Object.create(null); - for (const depName of graph.getTransitiveDependencies(project.getName())) { - const dep = graph.getProject(depName); - transitiveDependencyInfo[depName] = { - version: dep.getVersion() - }; - } - return transitiveDependencyInfo; -} - -export default async function(project, graph, buildConfig, taskRepository, transitiveDependencyInfo, buildCache) { +export default async function(project, graph, buildConfig, taskRepository, buildSignature, cache) { if (!project) { throw new Error(`Missing parameter 'project'`); } @@ -41,9 +29,7 @@ export default async function(project, graph, buildConfig, taskRepository, trans if (!taskRepository) { throw new Error(`Missing parameter 'taskRepository'`); } - if (!buildCache) { - throw new Error(`Missing parameter 'buildCache'`); - } + const projectName = project.getName(); const type = project.getType(); @@ -62,20 +48,19 @@ export default async function(project, graph, buildConfig, taskRepository, trans `Unable to create archive metadata for project ${project.getName()}: ` + `Project type ${type} is currently not supported`); } - let buildManifest; - if (project.isFrameworkProject()) { - buildManifest = await createFrameworkManifest(project, buildConfig, taskRepository); - } else { - buildManifest = { - manifestVersion: "0.3", - timestamp: new Date().toISOString(), - dependencies: collectDepInfo(graph, project), - version: project.getVersion(), - namespace: project.getNamespace(), - tags: getSortedTags(project), - cacheKey: createCacheKey(project, graph, buildConfig, taskRepository), - }; - } + // let buildManifest; + // if (project.isFrameworkProject()) { + // buildManifest = await createFrameworkManifest(project, buildConfig, taskRepository); + // } else { + // buildManifest = { + // manifestVersion: "0.3", + // timestamp: new Date().toISOString(), + // dependencies: collectDepInfo(graph, project), + // version: project.getVersion(), + // namespace: project.getNamespace(), + // tags: getSortedTags(project), + // }; + // } const metadata = { project: { @@ -90,19 +75,23 @@ export default async function(project, graph, buildConfig, taskRepository, trans } } }, - buildManifest, - buildCache: await buildCache.serialize(), + buildManifest: createBuildManifest(project, buildConfig, taskRepository, buildSignature), }; + if (cache) { + metadata.cache = cache; + } + return metadata; } -async function createFrameworkManifest(project, buildConfig, taskRepository) { +async function createBuildManifest(project, buildConfig, taskRepository, buildSignature) { // Use legacy manifest version for framework libraries to ensure compatibility const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); const buildManifest = { - manifestVersion: "0.2", + manifestVersion: "1.0", timestamp: new Date().toISOString(), + buildSignature, versions: { builderVersion: builderVersion, projectVersion: await getVersion("@ui5/project"), @@ -122,17 +111,3 @@ async function createFrameworkManifest(project, buildConfig, taskRepository) { } return buildManifest; } - -export async function createCacheKey(project, graph, buildConfig, taskRepository) { - const depInfo = collectDepInfo(graph, project); - const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); - const projectVersion = await getVersion("@ui5/project"); - const fsVersion = await getVersion("@ui5/fs"); - - const key = `${builderVersion}-${projectVersion}-${fsVersion}-${builderFsVersion}-` + - `${JSON.stringify(buildConfig)}-${JSON.stringify(depInfo)}`; - const hash = crypto.createHash("sha256").update(key).digest("hex"); - - // Create a hash from the cache key - return `${project.getName()}-${project.getVersion()}-${hash}`; -} diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 5cdd01e6629..65313c81da1 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -298,6 +298,10 @@ class Project extends Specification { return this._getStyledReader(style); } + getStages() { + return {}; + } + #getWriter() { if (this.#currentWriter) { return this.#currentWriter; @@ -531,14 +535,14 @@ class Project extends Specification { if (!this.#workspaceSealed) { throw new Error(`Unable to import cached stages: Workspace is not sealed`); } - for (const {stage, reader} of stages) { - if (!this.#stages.includes(stage)) { - this.#stages.push(stage); + for (const {stageName, reader} of stages) { + if (!this.#stages.includes(stageName)) { + this.#stages.push(stageName); } if (reader) { - this.#writers.set(stage, [reader]); + this.#writers.set(stageName, [reader]); } else { - this.#writers.set(stage, []); + this.#writers.set(stageName, []); } } this.#currentVersion = 0; diff --git a/packages/project/lib/specifications/extensions/Task.js b/packages/project/lib/specifications/extensions/Task.js index c5aee60b7a0..dfb88fc83ec 100644 --- a/packages/project/lib/specifications/extensions/Task.js +++ b/packages/project/lib/specifications/extensions/Task.js @@ -31,6 +31,20 @@ class Task extends Extension { return (await this._getImplementation()).determineRequiredDependencies; } + /** + * @public + */ + async getBuildSignatureCallback() { + return (await this._getImplementation()).determineBuildSignature; + } + + /** + * @public + */ + async getExpectedOutputCallback() { + return (await this._getImplementation()).determineExpectedOutput; + } + /* === Internals === */ /** * @private diff --git a/packages/project/lib/specifications/types/Component.js b/packages/project/lib/specifications/types/Component.js index 8ca5b94df26..54a19df7f51 100644 --- a/packages/project/lib/specifications/types/Component.js +++ b/packages/project/lib/specifications/types/Component.js @@ -165,13 +165,13 @@ class Component extends ComponentProject { /** * @private * @param {object} config Configuration object - * @param {object} buildDescription Cache metadata object + * @param {object} buildManifest Cache metadata object */ - async _parseConfiguration(config, buildDescription) { - await super._parseConfiguration(config, buildDescription); + async _parseConfiguration(config, buildManifest) { + await super._parseConfiguration(config, buildManifest); - if (buildDescription) { - this._namespace = buildDescription.namespace; + if (buildManifest) { + this._namespace = buildManifest.namespace; return; } this._namespace = await this._getNamespace(); diff --git a/packages/project/lib/utils/sanitizeFileName.js b/packages/project/lib/utils/sanitizeFileName.js new file mode 100644 index 00000000000..8950705de2c --- /dev/null +++ b/packages/project/lib/utils/sanitizeFileName.js @@ -0,0 +1,44 @@ +import path from "node:path"; + +const forbiddenCharsRegex = /[^0-9a-zA-Z\-._]/g; +const windowsReservedNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; + +/** + * Sanitize a file name by replacing any characters not matching the allowed set with a dash. + * Additionally validate that the file name to make sure it is safe to use on various file systems. + * + * @param {string} fileName The file name to validate + * @returns {string} The sanitized file name + * @throws {Error} If the file name is empty, starts with a dot, contains a reserved value, or is too long + */ +export default function sanitizeFileName(fileName) { + if (!fileName) { + throw new Error("Illegal empty file name"); + } + if (fileName.startsWith(".")) { + throw new Error(`Illegal file name starting with a dot: ${fileName}`); + } + fileName = fileName.replaceAll(forbiddenCharsRegex, "-"); + + if (fileName.length > 255) { + throw new Error(`Illegal file name exceeding maximum length of 255 characters: ${fileName}`); + } + + if (windowsReservedNames.test(fileName)) { + throw new Error(`Illegal file name reserved on Windows systems: ${fileName}`); + } + + return fileName; +} + +export function getPathFromPackageName(pkgName) { + // If pkgName starts with a scope, that becomes a folder + if (pkgName.startsWith("@") && pkgName.includes("/")) { + // Split at first slash to get the scope and sanitize it without the "@" + const scope = sanitizeFileName(pkgName.substring(1, pkgName.indexOf("/"))); + // Get the rest of the package name + const pkg = pkgName.substring(pkgName.indexOf("/") + 1); + return path.join(`@${sanitizeFileName(scope)}`, sanitizeFileName(pkg)); + } + return sanitizeFileName(pkgName); +} From 928349e7546e608b3bec8a8997810b4308bab3ac Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 10 Dec 2025 15:24:00 +0100 Subject: [PATCH 015/223] refactor(builder): Rename task param 'buildCache' to 'cacheUtil' --- packages/builder/lib/tasks/minify.js | 7 ++++--- packages/builder/lib/tasks/replaceBuildtime.js | 7 ++++--- packages/builder/lib/tasks/replaceCopyright.js | 7 ++++--- packages/builder/lib/tasks/replaceVersion.js | 7 ++++--- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index f79c3391cd6..f4aa89d2fe0 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -16,6 +16,7 @@ import fsInterface from "@ui5/fs/fsInterface"; * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil + * @param {object} [parameters.cacheUtil] Cache utility instance * @param {object} parameters.options Options * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @param {boolean} [parameters.options.omitSourceMapResources=false] Whether source map resources shall @@ -26,12 +27,12 @@ import fsInterface from "@ui5/fs/fsInterface"; * @returns {Promise} Promise resolving with undefined once data has been written */ export default async function({ - workspace, taskUtil, buildCache, + workspace, taskUtil, cacheUtil, options: {pattern, omitSourceMapResources = false, useInputSourceMaps = true} }) { let resources = await workspace.byGlob(pattern); - if (buildCache.hasCache()) { - const changedPaths = buildCache.getChangedProjectResourcePaths(); + if (cacheUtil.hasCache()) { + const changedPaths = cacheUtil.getChangedProjectResourcePaths(); resources = resources.filter((resource) => changedPaths.has(resource.getPath())); } if (resources.length === 0) { diff --git a/packages/builder/lib/tasks/replaceBuildtime.js b/packages/builder/lib/tasks/replaceBuildtime.js index 2a3ff1caf22..19e3c853569 100644 --- a/packages/builder/lib/tasks/replaceBuildtime.js +++ b/packages/builder/lib/tasks/replaceBuildtime.js @@ -28,15 +28,16 @@ function getTimestamp() { * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files + * @param {object} [parameters.cacheUtil] Cache utility instance * @param {object} parameters.options Options * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, buildCache, options: {pattern}}) { +export default async function({workspace, cacheUtil, options: {pattern}}) { let resources = await workspace.byGlob(pattern); - if (buildCache.hasCache()) { - const changedPaths = buildCache.getChangedProjectResourcePaths(); + if (cacheUtil.hasCache()) { + const changedPaths = cacheUtil.getChangedProjectResourcePaths(); resources = resources.filter((resource) => changedPaths.has(resource.getPath())); } const timestamp = getTimestamp(); diff --git a/packages/builder/lib/tasks/replaceCopyright.js b/packages/builder/lib/tasks/replaceCopyright.js index 09cd302d9f0..927cd30c0f2 100644 --- a/packages/builder/lib/tasks/replaceCopyright.js +++ b/packages/builder/lib/tasks/replaceCopyright.js @@ -24,12 +24,13 @@ import stringReplacer from "../processors/stringReplacer.js"; * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files + * @param {object} [parameters.cacheUtil] Cache utility instance * @param {object} parameters.options Options * @param {string} parameters.options.copyright Replacement copyright * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, buildCache, options: {copyright, pattern}}) { +export default async function({workspace, cacheUtil, options: {copyright, pattern}}) { if (!copyright) { return; } @@ -38,8 +39,8 @@ export default async function({workspace, buildCache, options: {copyright, patte copyright = copyright.replace(/(?:\$\{currentYear\})/, new Date().getFullYear()); let resources = await workspace.byGlob(pattern); - if (buildCache.hasCache()) { - const changedPaths = buildCache.getChangedProjectResourcePaths(); + if (cacheUtil.hasCache()) { + const changedPaths = cacheUtil.getChangedProjectResourcePaths(); resources = resources.filter((resource) => changedPaths.has(resource.getPath())); } diff --git a/packages/builder/lib/tasks/replaceVersion.js b/packages/builder/lib/tasks/replaceVersion.js index 7d1a56ffed1..39192b44d03 100644 --- a/packages/builder/lib/tasks/replaceVersion.js +++ b/packages/builder/lib/tasks/replaceVersion.js @@ -14,16 +14,17 @@ import stringReplacer from "../processors/stringReplacer.js"; * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files + * @param {object} parameters.cacheUtil Cache utility instance * @param {object} parameters.options Options * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @param {string} parameters.options.version Replacement version * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, buildCache, options: {pattern, version}}) { +export default async function({workspace, cacheUtil, options: {pattern, version}}) { let resources = await workspace.byGlob(pattern); - if (buildCache.hasCache()) { - const changedPaths = buildCache.getChangedProjectResourcePaths(); + if (cacheUtil.hasCache()) { + const changedPaths = cacheUtil.getChangedProjectResourcePaths(); resources = resources.filter((resource) => changedPaths.has(resource.getPath())); } const processedResources = await stringReplacer({ From cc7371578b79ed54d51f65c0615c8e0bb8b4751a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 10 Dec 2025 16:00:04 +0100 Subject: [PATCH 016/223] refactor(project): Cleanup --- packages/project/lib/build/TaskRunner.js | 4 - .../project/lib/build/cache/BuildTaskCache.js | 22 +-- .../lib/build/cache/ProjectBuildCache.js | 129 +----------------- .../lib/build/helpers/ProjectBuildContext.js | 37 ----- .../lib/build/helpers/createBuildManifest.js | 13 -- 5 files changed, 8 insertions(+), 197 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index eb3668a2612..9066bb058a1 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -221,10 +221,6 @@ class TaskRunner { }, options, }; - // const invalidatedResources = this._buildCache.getDepsOfInvalidatedResourcesForTask(taskName); - // if (invalidatedResources) { - // params.invalidatedResources = invalidatedResources; - // } let dependencies; if (requiresDependencies) { diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 001cf546c4e..6de00fe0079 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -19,8 +19,8 @@ const log = getLogger("build:cache:BuildTaskCache"); * @typedef {object} TaskCacheMetadata * @property {RequestMetadata} [projectRequests] - Project resource requests * @property {RequestMetadata} [dependencyRequests] - Dependency resource requests - * @property {Object.} [resourcesRead] - Resources read by task - * @property {Object.} [resourcesWritten] - Resources written by task + * @property {Object} [resourcesRead] - Resources read by task + * @property {Object} [resourcesWritten] - Resources written by task */ function unionArray(arr, items) { @@ -38,24 +38,6 @@ function unionObject(target, obj) { } } -// async function createMetadataForResources(resourceMap) { -// const metadata = Object.create(null); -// await Promise.all(Object.keys(resourceMap).map(async (resourcePath) => { -// const resource = resourceMap[resourcePath]; -// if (resource.hash) { -// // Metadata object -// metadata[resourcePath] = resource; -// return; -// } -// // Resource instance -// metadata[resourcePath] = { -// integrity: await resource.getIntegrity(), -// lastModified: resource.getLastModified(), -// }; -// })); -// return metadata; -// } - /** * Manages the build cache for a single task * diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 1a3df9f9cd2..df5ca440e23 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -12,7 +12,6 @@ export default class ProjectBuildCache { #project; #buildSignature; #cacheManager; - // #cacheDir; #invalidatedTasks = new Map(); #updatedResources = new Set(); @@ -30,10 +29,6 @@ export default class ProjectBuildCache { this.#project = project; this.#buildSignature = buildSignature; this.#cacheManager = cacheManager; - // this.#cacheRoot = cacheDir && createAdapter({ - // fsBasePath: cacheDir, - // virBasePath: "/" - // }); } static async create(project, buildSignature, cacheManager) { @@ -347,52 +342,7 @@ export default class ProjectBuildCache { return Array.from(this.#invalidatedTasks.keys()); } - // async createBuildManifest() { - // // const globalResourceIndex = Object.create(null); - // // function addResourcesToIndex(taskName, resourceMap) { - // // for (const resourcePath of Object.keys(resourceMap)) { - // // const resource = resourceMap[resourcePath]; - // // const resourceKey = `${resourcePath}:${resource.hash}`; - // // if (!globalResourceIndex[resourceKey]) { - // // globalResourceIndex[resourceKey] = { - // // hash: resource.hash, - // // lastModified: resource.lastModified, - // // tasks: [taskName] - // // }; - // // } else if (!globalResourceIndex[resourceKey].tasks.includes(taskName)) { - // // globalResourceIndex[resourceKey].tasks.push(taskName); - // // } - // // } - // // } - // const taskCache = []; - // for (const cache of this.#taskCache.values()) { - // const cacheObject = await cache.toJSON(); - // taskCache.push(cacheObject); - // // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesRead); - // // addResourcesToIndex(taskName, cacheObject.resources.project.resourcesWritten); - // // addResourcesToIndex(taskName, cacheObject.resources.dependencies.resourcesRead); - // } - // // Collect metadata for all relevant source files - // const sourceReader = this.#project.getSourceReader(); - // // const resourceMetadata = await Promise.all(Array.from(relevantSourceFiles).map(async (resourcePath) => { - // const resources = await sourceReader.byGlob("/**/*"); - // const sourceMetadata = Object.create(null); - // await Promise.all(resources.map(async (resource) => { - // sourceMetadata[resource.getOriginalPath()] = { - // lastModified: resource.getStatInfo()?.mtimeMs, - // hash: await resource.getHash(), - // }; - // })); - - // return { - // timestamp: Date.now(), - // cacheKey: this.#cacheKey, - // taskCache, - // sourceMetadata, - // // globalResourceIndex, - // }; - // } - + // ===== SERIALIZATION ===== async #createCacheManifest() { const cache = Object.create(null); cache.index = await this.#createIndex(this.#project.getSourceReader(), true); @@ -419,45 +369,8 @@ export default class ProjectBuildCache { await this.#cacheManager.writeBuildManifest( this.#project, this.#buildSignature, buildManifest); - - // const serializedCache = await this.toJSON(); - // const cacheContent = JSON.stringify(serializedCache, null, 2); - // const res = createResource({ - // path: `/cache-info.json`, - // string: cacheContent, - // }); - // await this.#cacheRoot.write(res); } - // async #serializeTaskOutputs() { - // log.info(`Serializing task outputs for project ${this.#project.getName()}`); - // const stageCache = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { - // const reader = this.#project.getDeltaReader(taskName); - // if (!reader) { - // log.verbose( - // `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` - // ); - // return; - // } - // const resources = await reader.byGlob("/**/*"); - - // const target = createAdapter({ - // fsBasePath: path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`), - // virBasePath: "/" - // }); - - // for (const res of resources) { - // await target.write(res); - // } - // return { - // reader: target, - // stage: taskName - // }; - // })); - // // Re-import cache as base layer to reduce memory pressure - // this.#project.importCachedStages(stageCache.filter((entry) => entry)); - // } - async #getStageNameForTask(taskName) { return `tasks/${taskName}`; } @@ -481,6 +394,7 @@ export default class ProjectBuildCache { this.#buildSignature, stageName, res.getOriginalPath(), await res.getStreamAsync() ); + // TODO: Decide whether to use stream or buffer // const integrity = await this.#cacheManager.writeStage( // this.#buildSignature, stageName, // res.getOriginalPath(), await res.getBuffer() @@ -510,7 +424,7 @@ export default class ProjectBuildCache { } // Check against index const resourcePath = resource.getOriginalPath(); - if (!index.hasOwnProperty(resourcePath)) { + if (Object.hasOwn(index, resourcePath)) { // New resource encountered log.verbose(`New source file: ${resourcePath}`); changedResources.add(resourcePath); @@ -518,19 +432,19 @@ export default class ProjectBuildCache { } const {lastModified, size, inode, integrity} = index[resourcePath]; - if (resourceMetadata.lastModified !== currentLastModified) { + if (lastModified !== currentLastModified) { log.verbose(`Source file modified: ${resourcePath} (timestamp change)`); changedResources.add(resourcePath); continue; } - if (resourceMetadata.inode !== resource.getInode()) { + if (inode !== resource.getInode()) { log.verbose(`Source file modified: ${resourcePath} (inode change)`); changedResources.add(resourcePath); continue; } - if (resourceMetadata.size !== await resource.getSize()) { + if (size !== await resource.getSize()) { log.verbose(`Source file modified: ${resourcePath} (size change)`); changedResources.add(resourcePath); continue; @@ -601,23 +515,6 @@ export default class ProjectBuildCache { } async #importCachedStages(stages) { - // const cachedStages = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { - // // const fsBasePath = path.join(this.#cacheDir, "taskCache", `${idx}-${taskName}`); - // // let cacheReader; - // // if (await exists(fsBasePath)) { - // // cacheReader = createAdapter({ - // // name: `Cache reader for task ${taskName} in project ${this.#project.getName()}`, - // // fsBasePath, - // // virBasePath: "/", - // // project: this.#project, - // // }); - // // } - - // return { - // stage: taskName, - // reader: cacheReader - // }; - // })); const cachedStages = await Promise.all(Object.entries(stages).map(async ([stageName, resourceMetadata]) => { const reader = await this.#createReaderForStageCache(stageName, resourceMetadata); return { @@ -688,17 +585,3 @@ export default class ProjectBuildCache { } } } - -// async function exists(filePath) { -// try { -// await stat(filePath); -// return true; -// } catch (err) { -// // "File or directory does not exist" -// if (err.code === "ENOENT") { -// return false; -// } else { -// throw err; -// } -// } -// } diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 375c95d59b2..547f85076e5 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -45,10 +45,6 @@ class ProjectBuildContext { allowedTags: ["ui5:OmitFromBuildResult", "ui5:IsBundle"], allowedNamespaces: ["build"] }); - // const buildManifest = this.#getBuildManifest(); - // if (buildManifest) { - // this._buildCache.deserialize(buildManifest.buildManifest.cache); - // } } static async create(buildContext, project) { @@ -162,10 +158,6 @@ class ProjectBuildContext { return false; } - // if (!this._buildCache.hasAnyCache()) { - // await this._buildCache.attemptDeserializationFromDisk(); - // } - return this._buildCache.needsRebuild(); } @@ -228,35 +220,6 @@ class ProjectBuildContext { getBuildSignature() { return this._buildSignature; } - - // async watchFileChanges() { - // // const paths = this._project.getSourcePaths(); - // // this._log.verbose(`Watching source paths: ${paths.join(", ")}`); - // // const {default: chokidar} = await import("chokidar"); - // // const watcher = chokidar.watch(paths, { - // // ignoreInitial: true, - // // persistent: false, - // // }); - // // watcher.on("add", async (filePath) => { - // // }); - // // watcher.on("change", async (filePath) => { - // // const resourcePath = this._project.getVirtualPath(filePath); - // // this._log.info(`File changed: ${resourcePath} (${filePath})`); - // // // Inform cache - // // this._buildCache.fileChanged(resourcePath); - // // // Inform dependents - // // for (const dependent of this._buildContext.getGraph().getTransitiveDependents(this._project.getName())) { - // // await this._buildContext.getProjectBuildContext(dependent).dependencyFileChanged(resourcePath); - // // } - // // // Inform build context - // // await this._buildContext.fileChanged(this._project.getName(), resourcePath); - // // }); - // } - - // dependencyFileChanged(resourcePath) { - // this._log.info(`Dependency file changed: ${resourcePath}`); - // this._buildCache.fileChanged(resourcePath); - // } } export default ProjectBuildContext; diff --git a/packages/project/lib/build/helpers/createBuildManifest.js b/packages/project/lib/build/helpers/createBuildManifest.js index 1a80bae840c..ba679479690 100644 --- a/packages/project/lib/build/helpers/createBuildManifest.js +++ b/packages/project/lib/build/helpers/createBuildManifest.js @@ -48,19 +48,6 @@ export default async function(project, graph, buildConfig, taskRepository, build `Unable to create archive metadata for project ${project.getName()}: ` + `Project type ${type} is currently not supported`); } - // let buildManifest; - // if (project.isFrameworkProject()) { - // buildManifest = await createFrameworkManifest(project, buildConfig, taskRepository); - // } else { - // buildManifest = { - // manifestVersion: "0.3", - // timestamp: new Date().toISOString(), - // dependencies: collectDepInfo(graph, project), - // version: project.getVersion(), - // namespace: project.getNamespace(), - // tags: getSortedTags(project), - // }; - // } const metadata = { project: { From 1426e4ed2b6a9bb91d0f0eca8cc519222e4a8e6a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 12 Dec 2025 14:44:22 +0100 Subject: [PATCH 017/223] refactor(project): Move resource comparison to util --- .../project/lib/build/cache/BuildTaskCache.js | 73 +++---------------- packages/project/lib/build/cache/utils.js | 66 +++++++++++++++++ 2 files changed, 77 insertions(+), 62 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 6de00fe0079..cad46294a65 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -1,6 +1,6 @@ import micromatch from "micromatch"; import {getLogger} from "@ui5/logger"; -import {createResourceIndex} from "./utils.js"; +import {createResourceIndex, areResourcesEqual} from "./utils.js"; const log = getLogger("build:cache:BuildTaskCache"); /** @@ -9,12 +9,6 @@ const log = getLogger("build:cache:BuildTaskCache"); * @property {string[]} patterns - Glob patterns used to read resources */ -/** - * @typedef {object} ResourceMetadata - * @property {string} hash - Content hash of the resource - * @property {number} lastModified - Last modified timestamp (mtimeMs) - */ - /** * @typedef {object} TaskCacheMetadata * @property {RequestMetadata} [projectRequests] - Project resource requests @@ -199,11 +193,11 @@ export default class BuildTaskCache { if (!cachedResource) { return false; } - if (cachedResource.hash) { - return this.#isResourceFingerprintEqual(resource, cachedResource); - } else { - return this.#isResourceEqual(resource, cachedResource); - } + // if (cachedResource.integrity) { + // return await matchIntegrity(resource, cachedResource); + // } else { + return await areResourcesEqual(resource, cachedResource); + // } } /** @@ -217,56 +211,11 @@ export default class BuildTaskCache { if (!cachedResource) { return false; } - if (cachedResource.hash) { - return this.#isResourceFingerprintEqual(resource, cachedResource); - } else { - return this.#isResourceEqual(resource, cachedResource); - } - } - - /** - * Compares two resource instances for equality - * - * @param {object} resourceA - First resource to compare - * @param {object} resourceB - Second resource to compare - * @returns {Promise} True if resources are equal - * @throws {Error} If either resource is undefined - */ - async #isResourceEqual(resourceA, resourceB) { - if (!resourceA || !resourceB) { - throw new Error("Cannot compare undefined resources"); - } - if (resourceA === resourceB) { - return true; - } - if (resourceA.getStatInfo()?.mtimeMs !== resourceB.getStatInfo()?.mtimeMs) { - return false; - } - if (await resourceA.getString() === await resourceB.getString()) { - return true; - } - return false; - } - - /** - * Compares a resource instance with cached metadata fingerprint - * - * @param {object} resourceA - Resource instance to compare - * @param {ResourceMetadata} resourceBMetadata - Cached metadata to compare against - * @returns {Promise} True if resource matches the fingerprint - * @throws {Error} If resource or metadata is undefined - */ - async #isResourceFingerprintEqual(resourceA, resourceBMetadata) { - if (!resourceA || !resourceBMetadata) { - throw new Error("Cannot compare undefined resources"); - } - if (resourceA.getStatInfo()?.mtimeMs !== resourceBMetadata.lastModified) { - return false; - } - if (await resourceA.getHash() === resourceBMetadata.hash) { - return true; - } - return false; + // if (cachedResource.integrity) { + // return await matchIntegrity(resource, cachedResource); + // } else { + return await areResourcesEqual(resource, cachedResource); + // } } #isRelevantResourceChange({pathsRead, patterns}, changedResourcePaths) { diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 387d69aeada..cd45c9f3444 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -1,3 +1,69 @@ +/** + * @typedef {object} ResourceMetadata + * @property {string} integrity Content integrity of the resource + * @property {number} lastModified Last modified timestamp (mtimeMs) + * @property {number} inode Inode number of the resource + * @property {number} size Size of the resource in bytes + */ + +/** + * Compares two resource instances for equality + * + * @param {object} resourceA - First resource to compare + * @param {object} resourceB - Second resource to compare + * @returns {Promise} True if resources are equal + * @throws {Error} If either resource is undefined + */ +export async function areResourcesEqual(resourceA, resourceB) { + if (!resourceA || !resourceB) { + throw new Error("Cannot compare undefined resources"); + } + if (resourceA === resourceB) { + return true; + } + if (resourceA.getOriginalPath() !== resourceB.getOriginalPath()) { + throw new Error("Cannot compare resources with different original paths"); + } + if (resourceA.getLastModified() !== resourceB.getLastModified()) { + return false; + } + if (await resourceA.getSize() !== resourceB.getSize()) { + return false; + } + // if (await resourceA.getString() === await resourceB.getString()) { + // return true; + // } + return false; +} + +// /** +// * Compares a resource instance with cached metadata fingerprint +// * +// * @param {object} resourceA - Resource instance to compare +// * @param {ResourceMetadata} resourceBMetadata - Cached metadata to compare against +// * @param {number} indexTimestamp - Timestamp of the index creation +// * @returns {Promise} True if resource matches the fingerprint +// * @throws {Error} If resource or metadata is undefined +// */ +// export async function matchResourceMetadata(resourceA, resourceBMetadata, indexTimestamp) { +// if (!resourceA || !resourceBMetadata) { +// throw new Error("Cannot compare undefined resources"); +// } +// if (resourceA.getLastModified() !== resourceBMetadata.lastModified) { +// return false; +// } +// if (await resourceA.getSize() !== resourceBMetadata.size) { +// return false; +// } +// if (resourceBMetadata.inode && resourceA.getInode() !== resourceBMetadata.inode) { +// return false; +// } +// if (await resourceA.getIntegrity() === resourceBMetadata.integrity) { +// return true; +// } +// return false; +// } + export async function createResourceIndex(resources, includeInode = false) { const index = Object.create(null); await Promise.all(resources.map(async (resource) => { From 9d551f8d9a7471cf014808712fbb3b2947c4c0d8 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 16 Dec 2025 11:29:05 +0100 Subject: [PATCH 018/223] refactor(project): Refactor stage handling --- packages/project/lib/build/TaskRunner.js | 1 + .../lib/build/cache/ProjectBuildCache.js | 195 ++++---- .../lib/specifications/ComponentProject.js | 2 +- .../project/lib/specifications/Project.js | 429 +++++++++++------- .../lib/specifications/types/Module.js | 11 +- .../lib/specifications/types/ThemeLibrary.js | 13 +- 6 files changed, 388 insertions(+), 263 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 9066bb058a1..a88f1f69409 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -122,6 +122,7 @@ class TaskRunner { }); this._log.setTasks(allTasks); + this._buildCache.setTasks(allTasks); for (const taskName of allTasks) { const taskFunction = this._tasks[taskName].task; diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index df5ca440e23..779604def3f 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -65,74 +65,77 @@ export default class ProjectBuildCache { } const resourcesWritten = projectTrackingResults.resourcesWritten; - if (this.#taskCache.has(taskName)) { - log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); - const taskCache = this.#taskCache.get(taskName); + if (!this.#taskCache.has(taskName)) { + throw new Error(`Cannot record results for unknown task ${taskName} ` + + `in project ${this.#project.getName()}`); + } + log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); + const taskCache = this.#taskCache.get(taskName); + + const writtenResourcePaths = Object.keys(resourcesWritten); + if (writtenResourcePaths.length) { + log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); + + const changedPaths = new Set((await Promise.all(writtenResourcePaths + .map(async (resourcePath) => { + // Check whether resource content actually changed + if (await taskCache.hasResourceInWriteCache(resourcesWritten[resourcePath])) { + return undefined; + } + return resourcePath; + }))).filter((resourcePath) => resourcePath !== undefined)); - const writtenResourcePaths = Object.keys(resourcesWritten); - if (writtenResourcePaths.length) { - log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); - - const changedPaths = new Set((await Promise.all(writtenResourcePaths - .map(async (resourcePath) => { - // Check whether resource content actually changed - if (await taskCache.hasResourceInWriteCache(resourcesWritten[resourcePath])) { - return undefined; - } - return resourcePath; - }))).filter((resourcePath) => resourcePath !== undefined)); - - if (!changedPaths.size) { - log.verbose( - `Resources produced by task ${taskName} match with cache from previous executions. ` + - `This task will not invalidate any other tasks`); - return; - } + if (!changedPaths.size) { log.verbose( - `Task ${taskName} produced ${changedPaths.size} resources that might invalidate other tasks`); - for (const resourcePath of changedPaths) { - this.#updatedResources.add(resourcePath); + `Resources produced by task ${taskName} match with cache from previous executions. ` + + `This task will not invalidate any other tasks`); + return; + } + log.verbose( + `Task ${taskName} produced ${changedPaths.size} resources that might invalidate other tasks`); + for (const resourcePath of changedPaths) { + this.#updatedResources.add(resourcePath); + } + // Check whether other tasks need to be invalidated + const allTasks = Array.from(this.#taskCache.keys()); + const taskIdx = allTasks.indexOf(taskName); + const emptySet = new Set(); + for (let i = taskIdx + 1; i < allTasks.length; i++) { + const nextTaskName = allTasks[i]; + if (!this.#taskCache.get(nextTaskName).matchesChangedResources(changedPaths, emptySet)) { + continue; } - // Check whether other tasks need to be invalidated - const allTasks = Array.from(this.#taskCache.keys()); - const taskIdx = allTasks.indexOf(taskName); - const emptySet = new Set(); - for (let i = taskIdx + 1; i < allTasks.length; i++) { - const nextTaskName = allTasks[i]; - if (!this.#taskCache.get(nextTaskName).matchesChangedResources(changedPaths, emptySet)) { - continue; - } - if (this.#invalidatedTasks.has(taskName)) { - const {changedDependencyResourcePaths} = - this.#invalidatedTasks.get(taskName); - for (const resourcePath of changedPaths) { - changedDependencyResourcePaths.add(resourcePath); - } - } else { - this.#invalidatedTasks.set(taskName, { - changedProjectResourcePaths: changedPaths, - changedDependencyResourcePaths: emptySet - }); + if (this.#invalidatedTasks.has(taskName)) { + const {changedDependencyResourcePaths} = + this.#invalidatedTasks.get(taskName); + for (const resourcePath of changedPaths) { + changedDependencyResourcePaths.add(resourcePath); } + } else { + this.#invalidatedTasks.set(taskName, { + changedProjectResourcePaths: changedPaths, + changedDependencyResourcePaths: emptySet + }); } } - taskCache.updateMetadata( - projectTrackingResults.requests, - dependencyTrackingResults?.requests, - resourcesRead, - resourcesWritten - ); - } else { - log.verbose(`Initializing build cache for task ${taskName} in project ${this.#project.getName()}`); - this.#taskCache.set(taskName, - new BuildTaskCache(this.#project.getName(), taskName, { - projectRequests: projectTrackingResults.requests, - dependencyRequests: dependencyTrackingResults?.requests, - resourcesRead, - resourcesWritten - }) - ); } + taskCache.updateMetadata( + projectTrackingResults.requests, + dependencyTrackingResults?.requests, + resourcesRead, + resourcesWritten + ); + // } else { + // log.verbose(`Initializing build cache for task ${taskName} in project ${this.#project.getName()}`); + // this.#taskCache.set(taskName, + // new BuildTaskCache(this.#project.getName(), taskName, { + // projectRequests: projectTrackingResults.requests, + // dependencyRequests: dependencyTrackingResults?.requests, + // resourcesRead, + // resourcesWritten + // }) + // ); + // } if (this.#invalidatedTasks.has(taskName)) { this.#invalidatedTasks.delete(taskName); @@ -300,6 +303,24 @@ export default class ProjectBuildCache { return !this.hasAnyCache() || this.#invalidatedTasks.size > 0; } + async setTasks(taskNames) { + // Ensure task cache entries exist for all tasks + for (const taskName of taskNames) { + if (!this.#taskCache.has(taskName)) { + this.#taskCache.set(taskName, + new BuildTaskCache(this.#project.getName(), taskName, { + projectRequests: [], + dependencyRequests: [], + resourcesRead: {}, + resourcesWritten: {} + }) + ); + } + } + const stageNames = taskNames.map((taskName) => this.#getStageNameForTask(taskName)); + this.#project.ensureStages(stageNames); + } + async prepareTaskExecution(taskName, dependencyReader) { // Check cache exists and ensure it's still valid before using it if (this.hasTaskCache(taskName)) { @@ -372,41 +393,43 @@ export default class ProjectBuildCache { } async #getStageNameForTask(taskName) { - return `tasks/${taskName}`; + return `task/${taskName}`; } async #saveCachedStages() { log.info(`Storing task outputs for project ${this.#project.getName()} in cache...`); - const stageCache = await Promise.all(Array.from(this.#taskCache.keys()).map(async (taskName, idx) => { - const stageName = this.#getStageNameForTask(taskName); - const reader = this.#project.getDeltaReader(stageName); - if (!reader) { - log.verbose( - `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` - ); - return; - } + + const stageMetadata = Object.create(null); + await Promise.all(this.#project.getStagesForCache().map(async ({stageId, reader}) => { + // if (!reader) { + // log.verbose( + // `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` + // ); + // return; + // } const resources = await reader.byGlob("/**/*"); + const metadata = stageMetadata[stageId]; - for (const res of resources) { + await Promise.all(resources.map(async (res) => { // Store resource content in cacache via CacheManager const integrity = await this.#cacheManager.writeStageStream( - this.#buildSignature, stageName, + this.#buildSignature, stageId, res.getOriginalPath(), await res.getStreamAsync() ); // TODO: Decide whether to use stream or buffer // const integrity = await this.#cacheManager.writeStage( - // this.#buildSignature, stageName, + // this.#buildSignature, stageId, // res.getOriginalPath(), await res.getBuffer() // ); - } - return { - reader: target, - stage: taskName - }; + + metadata[res.getOriginalPath()] = { + size: await res.getSize(), + lastModified: res.getLastModified(), + integrity, + }; + })); })); - // Re-import cache as base layer to reduce memory pressure - this.#project.importCachedStages(stageCache.filter((entry) => entry)); + // Optional TODO: Re-import cache as base layer to reduce memory pressure? } async #checkForIndexChanges(index, indexTimestamp) { @@ -515,14 +538,10 @@ export default class ProjectBuildCache { } async #importCachedStages(stages) { - const cachedStages = await Promise.all(Object.entries(stages).map(async ([stageName, resourceMetadata]) => { - const reader = await this.#createReaderForStageCache(stageName, resourceMetadata); - return { - stageName, - reader - }; + const readers = await Promise.all(Object.entries(stages).map(async ([stageName, resourceMetadata]) => { + return await this.#createReaderForStageCache(stageName, resourceMetadata); })); - this.#project.importCachedStages(cachedStages); + this.#project.setStages(Object.keys(stages), readers); } async saveToDisk(buildManifest) { diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index 34e1fd852ba..d3d5d7142f7 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -210,7 +210,7 @@ class ComponentProject extends Project { return reader; } - _addWriterToReaders(style, readers, writer) { + _addWriter(style, readers, writer) { let {namespaceWriter, generalWriter} = writer; if (!namespaceWriter || !generalWriter) { // TODO: Too hacky diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 65313c81da1..9031503583f 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -13,15 +13,13 @@ import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resour * @hideconstructor */ class Project extends Specification { - #currentWriter; - #currentWorkspace; - #currentReader = new Map(); - #currentStage; - #currentVersion = 0; // Writer version (0 is reserved for a possible imported writer cache) + #stages = []; // Stages in order of creation - #stages = [""]; // Stages in order of creation - #writers = new Map(); // Maps stage to a set of writer versions (possibly sparse array) - #workspaceSealed = true; // Project starts as being sealed. Needs to be unsealed using newVersion() + #currentStageWorkspace; + #currentStageReaders = new Map(); // Initialize an empty map to store the various reader styles + #currentStage; + #currentStageReadIndex = -1; + #currentStageName = ""; constructor(parameters) { super(parameters); @@ -273,20 +271,43 @@ class Project extends Specification { * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ getReader({style = "buildtime"} = {}) { - let reader = this.#currentReader.get(style); + let reader = this.#currentStageReaders.get(style); if (reader) { // Use cached reader return reader; } // const readers = []; - // this._addWriterToReaders(style, readers, this.getWriter()); + // this._addWriter(style, readers, this.getWriter()); // readers.push(this._getStyledReader(style)); // reader = createReaderCollectionPrioritized({ // name: `Reader collection for project ${this.getName()}`, // readers // }); - reader = this.#getReader(this.#currentStage, this.#currentVersion, style); - this.#currentReader.set(style, reader); + // reader = this.#getReader(this.#currentStage, style); + + const readers = []; + + // Add writers for previous stages as readers + const stageReadIdx = this.#currentStageReadIndex; + + // Collect writers from all relevant stages + for (let i = stageReadIdx; i >= 0; i--) { + const stageReaders = this.#getReaderForStage(this.#stages[i], style); + if (stageReaders) { + readers.push(); + } + } + + + // Always add source reader + readers.push(this._getStyledReader(style)); + + reader = createReaderCollectionPrioritized({ + name: `Reader collection for stage '${this.#currentStageName}' of project ${this.getName()}`, + readers: readers + }); + + this.#currentStageReaders.set(style, reader); return reader; } @@ -298,34 +319,9 @@ class Project extends Specification { return this._getStyledReader(style); } - getStages() { - return {}; - } - - #getWriter() { - if (this.#currentWriter) { - return this.#currentWriter; - } - - const stage = this.#currentStage; - const currentVersion = this.#currentVersion; - - if (!this.#writers.has(stage)) { - this.#writers.set(stage, []); - } - const versions = this.#writers.get(stage); - let writer; - if (versions[currentVersion]) { - writer = versions[currentVersion]; - } else { - // Create new writer - writer = this._createWriter(); - versions[currentVersion] = writer; - } - - this.#currentWriter = writer; - return writer; - } + // #getWriter() { + // return this.#currentStage.getWriter(); + // } // #createNewWriterStage(stageId) { // const writer = this._createWriter(); @@ -350,16 +346,17 @@ class Project extends Specification { * @returns {@ui5/fs/DuplexCollection} DuplexCollection */ getWorkspace() { - if (this.#workspaceSealed) { + if (!this.#currentStage) { throw new Error( - `Workspace of project ${this.getName()} has been sealed. This indicates that the project already ` + - `finished building and its content must not be modified further. ` + + `Workspace of project ${this.getName()} is currently not available. ` + + `This might indicate that the project has already finished building ` + + `and its content can not be modified further. ` + `Use method 'getReader' for read-only access`); } - if (this.#currentWorkspace) { - return this.#currentWorkspace; + if (this.#currentStageWorkspace) { + return this.#currentStageWorkspace; } - const writer = this.#getWriter(); + const writer = this.#currentStage.getWriter(); // if (this.#stageCacheReaders.has(this.getCurrentStage())) { // reader = createReaderCollectionPrioritized({ @@ -370,11 +367,12 @@ class Project extends Specification { // ] // }); // } - this.#currentWorkspace = createWorkspace({ + const workspace = createWorkspace({ reader: this.getReader(), writer: writer.collection || writer }); - return this.#currentWorkspace; + this.#currentStageWorkspace = workspace; + return workspace; } // getWorkspaceForVersion(version) { @@ -384,129 +382,154 @@ class Project extends Specification { // }); // } - sealWorkspace() { - this.#workspaceSealed = true; - this.useFinalStage(); - } - - newVersion() { - this.#workspaceSealed = false; - this.#currentVersion++; - this.useInitialStage(); - } - revertToLastVersion() { - if (this.#currentVersion === 0) { - throw new Error(`Unable to revert to previous version: No previous version available`); - } - this.#currentVersion--; - this.useInitialStage(); + // newVersion() { + // this.#workspaceSealed = false; + // this.#currentVersion++; + // this.useInitialStage(); + // } - // Remove writer version from all stages - for (const writerVersions of this.#writers.values()) { - if (writerVersions[this.#currentVersion]) { - delete writerVersions[this.#currentVersion]; - } - } - } + // revertToLastVersion() { + // if (this.#currentVersion === 0) { + // throw new Error(`Unable to revert to previous version: No previous version available`); + // } + // this.#currentVersion--; + // this.useInitialStage(); - #getReader(stage, version, style = "buildtime") { - const readers = []; + // // Remove writer version from all stages + // for (const writerVersions of this.#writers.values()) { + // if (writerVersions[this.#currentVersion]) { + // delete writerVersions[this.#currentVersion]; + // } + // } + // } - // Add writers for previous stages as readers - const stageIdx = this.#stages.indexOf(stage); - if (stageIdx > 0) { // Stage 0 has no previous stage - // Collect writers from all preceding stages - for (let i = stageIdx - 1; i >= 0; i--) { - const stageWriters = this.#getWriters(this.#stages[i], version, style); - if (stageWriters) { - readers.push(stageWriters); - } - } - } + // #getReader(style = "buildtime") { + // const readers = []; + + // // Add writers for previous stages as readers + // const stageIdx = this.#stages.findIndex((s) => s.getName() === stageId); + // if (stageIdx > 0) { // Stage 0 has no previous stage + // // Collect writers from all preceding stages + // for (let i = stageIdx - 1; i >= 0; i--) { + // const stageWriters = this.#getWriters(this.#stages[i], version, style); + // if (stageWriters) { + // readers.push(stageWriters); + // } + // } + // } - // Always add source reader - readers.push(this._getStyledReader(style)); + // // Always add source reader + // readers.push(this._getStyledReader(style)); - return createReaderCollectionPrioritized({ - name: `Reader collection for stage '${stage}' of project ${this.getName()}`, - readers: readers - }); - } + // return createReaderCollectionPrioritized({ + // name: `Reader collection for stage '${stage}' of project ${this.getName()}`, + // readers: readers + // }); + // } - useStage(stageId, newWriter = false) { + useStage(stageId) { // if (newWriter && this.#writers.has(stageId)) { // this.#writers.delete(stageId); // } - if (stageId === this.#currentStage) { + if (stageId === this.#currentStage.getId()) { + // Already using requested stage return; } - if (!this.#stages.includes(stageId)) { - // Add new stage - this.#stages.push(stageId); + + const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); + + if (stageIdx === -1) { + throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); } - this.#currentStage = stageId; + const stage = this.#stages[stageIdx]; + stage.newVersion(this._createWriter()); + this.#currentStage = stage; + this.#currentStageName = stageId; + this.#currentStageReadIndex = stageIdx - 1; // Read from all previous stages - // Unset "current" reader/writer - this.#currentReader = new Map(); - this.#currentWriter = null; - this.#currentWorkspace = null; + // Unset "current" reader/writer. They will be recreated on demand + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; } - useInitialStage() { - this.useStage(""); - } + /** + * Seal the workspace of the project, preventing further modifications. + * This is typically called once the project has finished building. Resources from all stages will be used. + * + * A project can be unsealed by calling useStage() again. + * + */ + sealWorkspace() { + this.#currentStage = null; // Unset stage - This blocks further getWorkspace() calls + this.#currentStageName = ""; + this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages - useFinalStage() { - this.useStage(""); + // Unset "current" reader/writer. They will be recreated on demand + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; } - #getWriters(stage, version, style = "buildtime") { + #getReaderForStage(stage, style = "buildtime", includeCache = true) { + const writers = stage.getAllWriters(includeCache); const readers = []; - const stageWriters = this.#writers.get(stage); - if (!stageWriters?.length) { - return null; - } - for (let i = version; i >= 0; i--) { - if (!stageWriters[i]) { - // Writers is a sparse array, some stages might skip a version - continue; - } - this._addWriterToReaders(style, readers, stageWriters[i]); + for (const writer of writers) { + // Apply project specific handling for using writers as readers, depending on the requested style + this._addWriter("buildtime", readers, writer); } return createReaderCollectionPrioritized({ - name: `Collection of all writers for stage '${stage}', version ${version} of project ${this.getName()}`, + name: `Reader collection for stage '${stage.getId()}' of project ${this.getName()}`, readers }); } - getDeltaReader(stage) { - const readers = []; - const stageWriters = this.#writers.get(stage); - if (!stageWriters?.length) { - return null; - } - const version = this.#currentVersion; - for (let i = version; i >= 1; i--) { // Skip version 0 (possibly containing cached writers) - if (!stageWriters[i]) { - // Writers is a sparse array, some stages might skip a version - continue; - } - this._addWriterToReaders("buildtime", readers, stageWriters[i]); - } + // #getWriters(stage, version, style = "buildtime") { + // const readers = []; + // const stageWriters = this.#writers.get(stage); + // if (!stageWriters?.length) { + // return null; + // } + // for (let i = version; i >= 0; i--) { + // if (!stageWriters[i]) { + // // Writers is a sparse array, some stages might skip a version + // continue; + // } + // this._addWriter(style, readers, stageWriters[i]); + // } - const reader = createReaderCollectionPrioritized({ - name: `Collection of new writers for stage '${stage}', version ${version} of project ${this.getName()}`, - readers - }); + // return createReaderCollectionPrioritized({ + // name: `Collection of all writers for stage '${stage}', version ${version} of project ${this.getName()}`, + // readers + // }); + // } + // getDeltaReader(stage) { + // const readers = []; + // const stageWriters = this.#writers.get(stage); + // if (!stageWriters?.length) { + // return null; + // } + // const version = this.#currentVersion; + // for (let i = version; i >= 1; i--) { // Skip version 0 (possibly containing cached writers) + // if (!stageWriters[i]) { + // // Writers is a sparse array, some stages might skip a version + // continue; + // } + // this._addWriter("buildtime", readers, stageWriters[i]); + // } - // Condense writer versions (TODO: this step is optional but might improve memory consumption) - // this.#condenseVersions(reader); - return reader; - } + // const reader = createReaderCollectionPrioritized({ + // name: `Collection of new writers for stage '${stage}', version ${version} of project ${this.getName()}`, + // readers + // }); + + + // // Condense writer versions (TODO: this step is optional but might improve memory consumption) + // // this.#condenseVersions(reader); + // return reader; + // } // #condenseVersions(reader) { // for (const stage of this.#stages) { @@ -531,30 +554,92 @@ class Project extends Specification { // } // } - importCachedStages(stages) { - if (!this.#workspaceSealed) { - throw new Error(`Unable to import cached stages: Workspace is not sealed`); - } - for (const {stageName, reader} of stages) { - if (!this.#stages.includes(stageName)) { - this.#stages.push(stageName); + getStagesForCache() { + return this.#stages.map((stage) => { + const reader = this.#getReaderForStage(stage, "buildtime", false); + return { + stageId: stage.getId(), + reader + }; + }); + } + + setStages(stageIds, cacheReaders) { + if (this.#stages.length > 0) { + // Stages have already been set. Compare existing stages with new ones and throw on mismatch + for (let i = 0; i < stageIds.length; i++) { + const stageId = stageIds[i]; + if (this.#stages[i].getId() !== stageId) { + throw new Error( + `Unable to set stages for project ${this.getName()}: Stage mismatch at position ${i} ` + + `(existing: ${this.#stages[i].getId()}, new: ${stageId})`); + } } - if (reader) { - this.#writers.set(stageName, [reader]); - } else { - this.#writers.set(stageName, []); + if (cacheReaders.length) { + throw new Error( + `Unable to set stages for project ${this.getName()}: Cache readers can only be set ` + + `when stages are created for the first time`); } + return; + } + for (let i = 0; i < stageIds.length; i++) { + const stageId = stageIds[i]; + const newStage = new Stage(stageId, cacheReaders?.[i]); + this.#stages.push(newStage); } - this.#currentVersion = 0; - this.useFinalStage(); - } - getCurrentStage() { - return this.#currentStage; + // let lastIdx; + // for (let i = 0; i < stageIds.length; i++) { + // const stageId = stageIds[i]; + // const idx = this.#stages.findIndex((s) => { + // return s.getName() === stageId; + // }); + // if (idx !== -1) { + // // Stage already exists, remember its position for later use + // lastIdx = idx; + // continue; + // } + // const newStage = new Stage(stageId, cacheReaders?.[i]); + // if (lastIdx !== undefined) { + // // Insert new stage after the last existing one to maintain order + // this.#stages.splice(lastIdx + 1, 0, newStage); + // lastIdx++; + // } else { + // // Append new stage + // this.#stages.push(newStage); + // } + // } } + // /** + // * Import cached stages into the project + // * + // * @param {Array<{stageName: string, reader: import("@ui5/fs").Reader}>} stages Stages to import + // */ + // importCachedStages(stages) { + // if (!this.#workspaceSealed) { + // throw new Error(`Unable to import cached stages: Workspace is not sealed`); + // } + // for (const {stageName, reader} of stages) { + // if (!this.#stages.includes(stageName)) { + // this.#stages.push(stageName); + // } + // if (reader) { + // this.#writers.set(stageName, [reader]); + // } else { + // this.#writers.set(stageName, []); + // } + // } + // this.#currentVersion = 0; + // this.useFinalStage(); + // } + + // getCurrentStage() { + // return this.#currentStage; + // } + /* Overwritten in ComponentProject subclass */ - _addWriterToReaders(style, readers, writer) { + _addWriter(style, readers, writer) { readers.push(writer); } @@ -583,4 +668,34 @@ class Project extends Specification { async _parseConfiguration(config) {} } +class Stage { + #id; + #writerVersions = []; + #cacheReader; + + constructor(id, cacheReader) { + this.#id = id; + this.#cacheReader = cacheReader; + } + + getId() { + return this.#id; + } + + newVersion(writer) { + this.#writerVersions.push(writer); + } + + getWriter() { + return this.#writerVersions[this.#writerVersions.length - 1]; + } + + getAllWriters(includeCache = true) { + if (includeCache && this.#cacheReader) { + return [this.#cacheReader, ...this.#writerVersions]; + } + return this.#writerVersions; + } +} + export default Project; diff --git a/packages/project/lib/specifications/types/Module.js b/packages/project/lib/specifications/types/Module.js index dcd3a9a2176..201dccfe130 100644 --- a/packages/project/lib/specifications/types/Module.js +++ b/packages/project/lib/specifications/types/Module.js @@ -16,7 +16,6 @@ class Module extends Project { super(parameters); this._paths = null; - this._writer = null; } /* === Attributes === */ @@ -83,13 +82,9 @@ class Module extends Project { } _createWriter() { - if (!this._writer) { - this._writer = resourceFactory.createAdapter({ - virBasePath: "/" - }); - } - - return this._writer; + return resourceFactory.createAdapter({ + virBasePath: "/" + }); } /* === Internals === */ diff --git a/packages/project/lib/specifications/types/ThemeLibrary.js b/packages/project/lib/specifications/types/ThemeLibrary.js index 9412975721e..b9f352f0a6c 100644 --- a/packages/project/lib/specifications/types/ThemeLibrary.js +++ b/packages/project/lib/specifications/types/ThemeLibrary.js @@ -18,7 +18,6 @@ class ThemeLibrary extends Project { this._srcPath = "src"; this._testPath = "test"; this._testPathExists = false; - this._writer = null; } /* === Attributes === */ @@ -113,14 +112,10 @@ class ThemeLibrary extends Project { } _createWriter() { - if (!this._writer) { - this._writer = resourceFactory.createAdapter({ - virBasePath: "/", - project: this - }); - } - - return this._writer; + return resourceFactory.createAdapter({ + virBasePath: "/", + project: this + }); } /* === Internals === */ From 6b55826ca289b7d83b0de1fc3f093423337a3729 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 16 Dec 2025 13:23:52 +0100 Subject: [PATCH 019/223] refactor(project): Fix cache handling --- packages/project/lib/build/ProjectBuilder.js | 24 +-- .../project/lib/build/cache/BuildTaskCache.js | 2 +- .../project/lib/build/cache/CacheManager.js | 38 ++--- .../lib/build/cache/ProjectBuildCache.js | 143 ++++++++++-------- .../lib/build/helpers/createBuildManifest.js | 12 +- .../lib/specifications/ComponentProject.js | 6 +- .../project/lib/specifications/Project.js | 15 +- 7 files changed, 133 insertions(+), 107 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 51bf831aee8..3ccb171faf0 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -135,6 +135,7 @@ class ProjectBuilder { * Alternative to the includedDependencies and excludedDependencies parameters. * Allows for a more sophisticated configuration for defining which dependencies should be * part of the build result. If this is provided, the other mentioned parameters are ignored. + * @param parameters.watch * @returns {Promise} Promise resolving once the build has finished */ async build({ @@ -258,13 +259,12 @@ class ProjectBuilder { this.#log.skipProjectBuild(projectName, projectType); } else { this.#log.startProjectBuild(projectName, projectType); - project.newVersion(); await projectBuildContext.getTaskRunner().runTasks(); - project.sealWorkspace(); this.#log.endProjectBuild(projectName, projectType); } - if (!requestedProjects.includes(projectName) || !!process.env.UI5_BUILD_NO_WRITE_DEST) { - // Project has not been requested or writing is disabled + project.sealWorkspace(); + if (!requestedProjects.includes(projectName)) { + // Project has not been requested // => Its resources shall not be part of the build result continue; } @@ -276,11 +276,11 @@ class ProjectBuilder { if (!alreadyBuilt.includes(projectName)) { this.#log.verbose(`Saving cache...`); - const metadata = await createBuildManifest( + const buildManifest = await createBuildManifest( project, this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().saveToDisk(metadata)); + pWrites.push(projectBuildContext.getBuildCache().saveToDisk(buildManifest)); } } await Promise.all(pWrites); @@ -351,7 +351,6 @@ class ProjectBuilder { } this.#log.startProjectBuild(projectName, projectType); - project.newVersion(); await projectBuildContext.runTasks(); project.sealWorkspace(); this.#log.endProjectBuild(projectName, projectType); @@ -367,8 +366,11 @@ class ProjectBuilder { } this.#log.verbose(`Updating cache...`); - // TODO: Serialize lazily, or based on memory pressure - pWrites.push(projectBuildContext.getBuildCache().saveToDisk()); + const buildManifest = await createBuildManifest( + project, + this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), + projectBuildContext.getBuildSignature()); + pWrites.push(projectBuildContext.getBuildCache().saveToDisk(buildManifest)); } await Promise.all(pWrites); } @@ -488,12 +490,12 @@ class ProjectBuilder { if (createBuildManifest) { // Create and write a build manifest metadata file - const metadata = await createBuildManifest( + const buildManifest = await createBuildManifest( project, this._graph, buildConfig, this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); await target.write(resourceFactory.createResource({ path: `/.ui5/build-manifest.json`, - string: JSON.stringify(metadata, null, "\t") + string: JSON.stringify(buildManifest, null, "\t") })); } diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index cad46294a65..c5ad3785a87 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -66,7 +66,7 @@ export default class BuildTaskCache { * @param {string} taskName - Name of the task * @param {TaskCacheMetadata} metadata - Task cache metadata */ - constructor(projectName, taskName, {projectRequests, dependencyRequests, input, output}) { + constructor(projectName, taskName, {projectRequests, dependencyRequests, input, output} = {}) { this.#projectName = projectName; this.#taskName = taskName; diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 0682c6ac75f..06fef6322bf 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -10,7 +10,7 @@ import Configuration from "../../config/Configuration.js"; import {getPathFromPackageName} from "../../utils/sanitizeFileName.js"; import {getLogger} from "@ui5/logger"; -const log = getLogger("project:build:cache:CacheManager"); +const log = getLogger("build:cache:CacheManager"); const chacheManagerInstances = new Map(); const CACACHE_OPTIONS = {algorithms: ["sha256"]}; @@ -24,8 +24,12 @@ const CACACHE_OPTIONS = {algorithms: ["sha256"]}; * */ export default class CacheManager { + #casDir; + #manifestDir; + constructor(cacheDir) { - this._cacheDir = cacheDir; + this.#casDir = path.join(cacheDir, "cas"); + this.#manifestDir = path.join(cacheDir, "buildManifests"); } static async create(cwd) { @@ -51,7 +55,7 @@ export default class CacheManager { #getBuildManifestPath(packageName, buildSignature) { const pkgDir = getPathFromPackageName(packageName); - return path.join(this._cacheDir, pkgDir, `${buildSignature}.json`); + return path.join(this.#manifestDir, pkgDir, `${buildSignature}.json`); } async readBuildManifest(project, buildSignature) { @@ -73,22 +77,22 @@ export default class CacheManager { await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); } - async getResourcePathForStage(buildSignature, stageName, resourcePath, integrity) { + async getResourcePathForStage(buildSignature, stageId, resourcePath, integrity) { // try { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } - const cacheKey = this.#createKeyForStage(buildSignature, stageName, resourcePath); - const result = await cacache.get.info(this._cacheDir, cacheKey); + const cacheKey = this.#createKeyForStage(buildSignature, stageId, resourcePath); + const result = await cacache.get.info(this.#casDir, cacheKey); if (result.integrity !== integrity) { log.info(`Integrity mismatch for cache entry ` + `${cacheKey}: expected ${integrity}, got ${result.integrity}`); - const res = await cacache.get.byDigest(this._cacheDir, result.integrity); + const res = await cacache.get.byDigest(this.#casDir, result.integrity); if (res) { log.info(`Updating cache entry with expectation...`); - await this.writeStage(buildSignature, stageName, resourcePath, res.data); - return await this.getResourcePathForStage(buildSignature, stageName, resourcePath, integrity); + await this.writeStage(buildSignature, stageId, resourcePath, res.data); + return await this.getResourcePathForStage(buildSignature, stageId, resourcePath, integrity); } } if (!result) { @@ -104,19 +108,19 @@ export default class CacheManager { // } } - async writeStage(buildSignature, stageName, resourcePath, buffer) { + async writeStage(buildSignature, stageId, resourcePath, buffer) { return await cacache.put( - this._cacheDir, - this.#createKeyForStage(buildSignature, stageName, resourcePath), + this.#casDir, + this.#createKeyForStage(buildSignature, stageId, resourcePath), buffer, CACACHE_OPTIONS ); } - async writeStageStream(buildSignature, stageName, resourcePath, stream) { + async writeStageStream(buildSignature, stageId, resourcePath, stream) { const writable = cacache.put.stream( - this._cacheDir, - this.#createKeyForStage(buildSignature, stageName, resourcePath), + this.#casDir, + this.#createKeyForStage(buildSignature, stageId, resourcePath), stream, CACACHE_OPTIONS, ); @@ -131,7 +135,7 @@ export default class CacheManager { }); } - #createKeyForStage(buildSignature, stageName, resourcePath) { - return `${buildSignature}|${stageName}|${resourcePath}`; + #createKeyForStage(buildSignature, stageId, resourcePath) { + return `${buildSignature}|${stageId}|${resourcePath}`; } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 779604def3f..42a76251679 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -66,8 +66,10 @@ export default class ProjectBuildCache { const resourcesWritten = projectTrackingResults.resourcesWritten; if (!this.#taskCache.has(taskName)) { - throw new Error(`Cannot record results for unknown task ${taskName} ` + - `in project ${this.#project.getName()}`); + // Initialize task cache + this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName)); + // throw new Error(`Cannot record results for unknown task ${taskName} ` + + // `in project ${this.#project.getName()}`); } log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); const taskCache = this.#taskCache.get(taskName); @@ -171,7 +173,7 @@ export default class ProjectBuildCache { resourceChanged(projectResourcePaths, dependencyResourcePaths) { let taskInvalidated = false; for (const [taskName, taskCache] of this.#taskCache) { - if (!taskCache.checkPossiblyInvalidatesTask(projectResourcePaths, dependencyResourcePaths)) { + if (!taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { continue; } taskInvalidated = true; @@ -304,21 +306,37 @@ export default class ProjectBuildCache { } async setTasks(taskNames) { - // Ensure task cache entries exist for all tasks - for (const taskName of taskNames) { - if (!this.#taskCache.has(taskName)) { - this.#taskCache.set(taskName, - new BuildTaskCache(this.#project.getName(), taskName, { - projectRequests: [], - dependencyRequests: [], - resourcesRead: {}, - resourcesWritten: {} - }) - ); - } - } + // if (this.#taskCache.size) { + // // If task cache entries already exist, validate they match the provided task names + // const existingTaskNames = Array.from(this.#taskCache.keys()); + // if (existingTaskNames.length !== taskNames.length || + // !existingTaskNames.every((taskName, idx) => taskName === taskNames[idx])) { + // throw new Error( + // `Cannot set tasks for project ${this.#project.getName()}: ` + + // `Existing cached tasks ${existingTaskNames.join(", ")} do not match ` + + // `provided task names ${taskNames.join(", ")}`); + // } + // return; + // } + // // Create task cache entries for all tasks and initialize stages + // for (const taskName of taskNames) { + // if (!this.#taskCache.has(taskName)) { + // this.#taskCache.set(taskName, + // new BuildTaskCache(this.#project.getName(), taskName, { + // projectRequests: [], + // dependencyRequests: [], + // resourcesRead: {}, + // resourcesWritten: {} + // }) + // ); + // this.#invalidatedTasks.set(taskName, { + // changedProjectResourcePaths: new Set(), + // changedDependencyResourcePaths: new Set(), + // }); + // } + // } const stageNames = taskNames.map((taskName) => this.#getStageNameForTask(taskName)); - this.#project.ensureStages(stageNames); + this.#project.setStages(stageNames); } async prepareTaskExecution(taskName, dependencyReader) { @@ -333,8 +351,7 @@ export default class ProjectBuildCache { } // Switch project to use cached stage as base layer - const stageName = this.#getStageNameForTask(taskName); - this.#project.useStage(stageName); + this.#project.useStage(this.#getStageNameForTask(taskName)); return true; // Task needs to be executed } @@ -374,9 +391,7 @@ export default class ProjectBuildCache { cache.taskMetadata[taskName] = await taskCache.createMetadata(); } - cache.stages = Object.create(null); - - // const stages = this.#project.getStages(); + cache.stages = await this.#saveCachedStages(); return cache; } @@ -392,15 +407,14 @@ export default class ProjectBuildCache { this.#project, this.#buildSignature, buildManifest); } - async #getStageNameForTask(taskName) { + #getStageNameForTask(taskName) { return `task/${taskName}`; } async #saveCachedStages() { log.info(`Storing task outputs for project ${this.#project.getName()} in cache...`); - const stageMetadata = Object.create(null); - await Promise.all(this.#project.getStagesForCache().map(async ({stageId, reader}) => { + return await Promise.all(this.#project.getStagesForCache().map(async ({stageId, reader}) => { // if (!reader) { // log.verbose( // `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` @@ -408,26 +422,26 @@ export default class ProjectBuildCache { // return; // } const resources = await reader.byGlob("/**/*"); - const metadata = stageMetadata[stageId]; - + const resourceMetadata = Object.create(null); await Promise.all(resources.map(async (res) => { // Store resource content in cacache via CacheManager - const integrity = await this.#cacheManager.writeStageStream( - this.#buildSignature, stageId, - res.getOriginalPath(), await res.getStreamAsync() - ); - // TODO: Decide whether to use stream or buffer - // const integrity = await this.#cacheManager.writeStage( + // const integrity = await this.#cacheManager.writeStageStream( // this.#buildSignature, stageId, - // res.getOriginalPath(), await res.getBuffer() + // res.getOriginalPath(), await res.getStreamAsync() // ); + // TODO: Decide whether to use stream or buffer + const integrity = await this.#cacheManager.writeStage( + this.#buildSignature, stageId, + res.getOriginalPath(), await res.getBuffer() + ); - metadata[res.getOriginalPath()] = { + resourceMetadata[res.getOriginalPath()] = { size: await res.getSize(), lastModified: res.getLastModified(), integrity, }; })); + return [stageId, resourceMetadata]; })); // Optional TODO: Re-import cache as base layer to reduce memory pressure? } @@ -439,6 +453,7 @@ export default class ProjectBuildCache { const changedResources = new Set(); for (const resource of resources) { const currentLastModified = resource.getLastModified(); + const resourcePath = resource.getOriginalPath(); if (currentLastModified > indexTimestamp) { // Resource modified after index was created, no need for further checks log.verbose(`Source file created or modified after index creation: ${resourcePath}`); @@ -446,8 +461,7 @@ export default class ProjectBuildCache { continue; } // Check against index - const resourcePath = resource.getOriginalPath(); - if (Object.hasOwn(index, resourcePath)) { + if (!Object.hasOwn(index, resourcePath)) { // New resource encountered log.verbose(`New source file: ${resourcePath}`); changedResources.add(resourcePath); @@ -486,17 +500,17 @@ export default class ProjectBuildCache { } } if (changedResources.size) { - const invalidatedTasks = this.markResourcesChanged(changedResources, new Set()); - if (invalidatedTasks.length > 0) { + const invalidatedTasks = this.resourceChanged(changedResources, new Set()); + if (invalidatedTasks) { log.info(`Invalidating tasks due to changed resources for project ${this.#project.getName()}`); } } } - async #createReaderForStageCache(stageName, resourceMetadata) { + async #createReaderForStageCache(stageId, resourceMetadata) { const allResourcePaths = Object.keys(resourceMetadata); return createProxy({ - name: `Cache reader for task ${stageName} in project ${this.#project.getName()}`, + name: `Cache reader for task ${stageId} in project ${this.#project.getName()}`, listResourcePaths: () => { return allResourcePaths; }, @@ -507,14 +521,14 @@ export default class ProjectBuildCache { const {lastModified, size, integrity} = resourceMetadata[virPath]; if (size === undefined || lastModified === undefined || integrity === undefined) { - throw new Error(`Incomplete metadata for resource ${virPath} of task ${stageName} ` + + throw new Error(`Incomplete metadata for resource ${virPath} of task ${stageId} ` + `in project ${this.#project.getName()}`); } // Get path to cached file contend stored in cacache via CacheManager - const cachePath = await this.#cacheManager.getPathForTaskResource( - this.#buildSignature, stageName, virPath, integrity); + const cachePath = await this.#cacheManager.getResourcePathForStage( + this.#buildSignature, stageId, virPath, integrity); if (!cachePath) { - log.warn(`Content of resource ${virPath} of task ${stageName} ` + + log.warn(`Content of resource ${virPath} of task ${stageId} ` + `in project ${this.#project.getName()}`); return null; } @@ -537,18 +551,25 @@ export default class ProjectBuildCache { }); } + async #importCachedTasks(taskMetadata) { + for (const [taskName, metadata] of Object.entries(taskMetadata)) { + this.#taskCache.set(taskName, + new BuildTaskCache(this.#project.getName(), taskName, metadata)); + } + } + async #importCachedStages(stages) { - const readers = await Promise.all(Object.entries(stages).map(async ([stageName, resourceMetadata]) => { - return await this.#createReaderForStageCache(stageName, resourceMetadata); + const readers = await Promise.all(stages.map(async ([stageId, resourceMetadata]) => { + return await this.#createReaderForStageCache(stageId, resourceMetadata); })); - this.#project.setStages(Object.keys(stages), readers); + this.#project.setStages(stages.map(([id]) => id), readers); } async saveToDisk(buildManifest) { - await Promise.all([ - await this.#saveCachedStages(), - await this.#saveBuildManifest(buildManifest) - ]); + await this.#saveBuildManifest(buildManifest); + // await Promise.all([ + // await this.#saveCachedStages(); + // ]); } /** @@ -570,7 +591,8 @@ export default class ProjectBuildCache { try { // Check build manifest version - if (manifest.version !== "1.0") { + const {buildManifest, cache} = manifest; + if (buildManifest.manifestVersion !== "1.0") { log.verbose(`Incompatible build manifest version ${manifest.version} found for project ` + `${this.#project.getName()} with build signature ${this.#buildSignature}. Ignoring cache.`); return; @@ -587,15 +609,18 @@ export default class ProjectBuildCache { `Restoring build cache for project ${this.#project.getName()} from build manifest ` + `with signature ${this.#buildSignature}`); - const {cache} = manifest; - for (const [taskName, metadata] of Object.entries(cache.tasksMetadata)) { - this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, metadata)); - } + // for (const [taskName, metadata] of Object.entries(cache.taskMetadata)) { + // this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, metadata)); + // } + + // Import task- and stage metadata first and in parallel await Promise.all([ - this.#checkForIndexChanges(cache.index, cache.indexTimestamp), + this.#importCachedTasks(cache.taskMetadata), this.#importCachedStages(cache.stages), ]); - // this.#buildManifest = manifest; + + // After tasks have been imported, check for source changes (and potentially invalidate tasks) + await this.#checkForIndexChanges(cache.index, cache.indexTimestamp); } catch (err) { throw new Error( `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { diff --git a/packages/project/lib/build/helpers/createBuildManifest.js b/packages/project/lib/build/helpers/createBuildManifest.js index ba679479690..2f95b6fa363 100644 --- a/packages/project/lib/build/helpers/createBuildManifest.js +++ b/packages/project/lib/build/helpers/createBuildManifest.js @@ -16,7 +16,7 @@ function getSortedTags(project) { return Object.fromEntries(entities); } -export default async function(project, graph, buildConfig, taskRepository, buildSignature, cache) { +export default async function(project, graph, buildConfig, taskRepository, signature) { if (!project) { throw new Error(`Missing parameter 'project'`); } @@ -62,23 +62,19 @@ export default async function(project, graph, buildConfig, taskRepository, build } } }, - buildManifest: createBuildManifest(project, buildConfig, taskRepository, buildSignature), + buildManifest: await createBuildManifest(project, buildConfig, taskRepository, signature), }; - if (cache) { - metadata.cache = cache; - } - return metadata; } -async function createBuildManifest(project, buildConfig, taskRepository, buildSignature) { +async function createBuildManifest(project, buildConfig, taskRepository, signature) { // Use legacy manifest version for framework libraries to ensure compatibility const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); const buildManifest = { manifestVersion: "1.0", timestamp: new Date().toISOString(), - buildSignature, + signature, versions: { builderVersion: builderVersion, projectVersion: await getVersion("@ui5/project"), diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index d3d5d7142f7..e78047d1f5e 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -171,19 +171,19 @@ class ComponentProject extends Project { _createWriter() { // writer is always of style "buildtime" const namespaceWriter = resourceFactory.createAdapter({ - name: `Namespace writer for project ${this.getName()} (${this.getCurrentStage()} stage)`, + name: `Namespace writer for project ${this.getName()}`, virBasePath: "/", project: this }); const generalWriter = resourceFactory.createAdapter({ - name: `General writer for project ${this.getName()} (${this.getCurrentStage()} stage)`, + name: `General writer for project ${this.getName()}`, virBasePath: "/", project: this }); const collection = resourceFactory.createWriterCollection({ - name: `Writers for project ${this.getName()} (${this.getCurrentStage()} stage)`, + name: `Writers for project ${this.getName()}`, writerMapping: { [`/resources/${this._namespace}/`]: namespaceWriter, [`/test-resources/${this._namespace}/`]: namespaceWriter, diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 9031503583f..8543e2294ce 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -292,13 +292,12 @@ class Project extends Specification { // Collect writers from all relevant stages for (let i = stageReadIdx; i >= 0; i--) { - const stageReaders = this.#getReaderForStage(this.#stages[i], style); - if (stageReaders) { - readers.push(); + const stageReader = this.#getReaderForStage(this.#stages[i], style); + if (stageReader) { + readers.push(stageReader); } } - // Always add source reader readers.push(this._getStyledReader(style)); @@ -432,7 +431,7 @@ class Project extends Specification { // if (newWriter && this.#writers.has(stageId)) { // this.#writers.delete(stageId); // } - if (stageId === this.#currentStage.getId()) { + if (stageId === this.#currentStage?.getId()) { // Already using requested stage return; } @@ -476,7 +475,7 @@ class Project extends Specification { const readers = []; for (const writer of writers) { // Apply project specific handling for using writers as readers, depending on the requested style - this._addWriter("buildtime", readers, writer); + this._addWriter(style, readers, writer); } return createReaderCollectionPrioritized({ @@ -575,12 +574,12 @@ class Project extends Specification { `(existing: ${this.#stages[i].getId()}, new: ${stageId})`); } } - if (cacheReaders.length) { + if (cacheReaders?.length) { throw new Error( `Unable to set stages for project ${this.getName()}: Cache readers can only be set ` + `when stages are created for the first time`); } - return; + return; // Stages already set and matching, no further processing needed } for (let i = 0; i < stageIds.length; i++) { const stageId = stageIds[i]; From 4dafac3f5fa859340e1cb257aa6e48c4abf87716 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 16 Dec 2025 15:52:15 +0100 Subject: [PATCH 020/223] refactor(fs): Remove contentAccess mutex timeout from Resource --- packages/fs/lib/Resource.js | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index 228f365c86b..6cdc3a7531f 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -4,7 +4,7 @@ import ssri from "ssri"; import clone from "clone"; import posixPath from "node:path/posix"; import {setTimeout} from "node:timers/promises"; -import {withTimeout, Mutex} from "async-mutex"; +import {Mutex} from "async-mutex"; import {getLogger} from "@ui5/logger"; const log = getLogger("fs:Resource"); @@ -52,8 +52,8 @@ class Resource { /* States */ #isModified = false; - // Mutex to prevent access/modification while content is being transformed. 100 ms timeout - #contentMutex = withTimeout(new Mutex(), 100, new Error("Timeout waiting for resource content access")); + // Mutex to prevent access/modification while content is being transformed + #contentMutex = new Mutex(); // Tracing #collections = []; @@ -404,20 +404,23 @@ class Resource { } // Then make sure no other operation is currently modifying the content and then lock it const release = await this.#contentMutex.acquire(); - const newContent = await callback(this.#getStream()); - - // New content is either buffer or stream - if (Buffer.isBuffer(newContent)) { - this.#content = newContent; - this.#contentType = CONTENT_TYPES.BUFFER; - } else if (typeof newContent === "object" && typeof newContent.pipe === "function") { - this.#content = newContent; - this.#contentType = CONTENT_TYPES.STREAM; - } else { - throw new Error("Unable to set new content: Content must be either a Buffer or a Readable Stream"); + try { + const newContent = await callback(this.#getStream()); + + // New content is either buffer or stream + if (Buffer.isBuffer(newContent)) { + this.#content = newContent; + this.#contentType = CONTENT_TYPES.BUFFER; + } else if (typeof newContent === "object" && typeof newContent.pipe === "function") { + this.#content = newContent; + this.#contentType = CONTENT_TYPES.STREAM; + } else { + throw new Error("Unable to set new content: Content must be either a Buffer or a Readable Stream"); + } + this.#contendModified(); + } finally { + release(); } - this.#contendModified(); - release(); } /** From 864d82b36a830bae6c77241b787484e18a59b6b5 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 16 Dec 2025 16:07:55 +0100 Subject: [PATCH 021/223] refactor(project): Cleanup obsolete code/comments --- packages/project/lib/build/ProjectBuilder.js | 19 +- .../project/lib/build/cache/CacheManager.js | 8 - .../lib/build/cache/ProjectBuildCache.js | 58 ----- .../project/lib/specifications/Project.js | 209 ------------------ 4 files changed, 1 insertion(+), 293 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 3ccb171faf0..b7ae8ad59d8 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -135,7 +135,7 @@ class ProjectBuilder { * Alternative to the includedDependencies and excludedDependencies parameters. * Allows for a more sophisticated configuration for defining which dependencies should be * part of the build result. If this is provided, the other mentioned parameters are ignored. - * @param parameters.watch + * @param {boolean} [parameters.watch] Whether to watch for file changes and re-execute the build automatically * @returns {Promise} Promise resolving once the build has finished */ async build({ @@ -301,21 +301,6 @@ class ProjectBuilder { await this.#update(projectBuildContexts, requestedProjects, fsTarget); }); return watchHandler; - - // Register change handler - // this._buildContext.onSourceFileChange(async (event) => { - // await this.#update(projectBuildContexts, requestedProjects, - // fsTarget, - // targetWriterProject, targetWriterDependencies); - // updateOnChange(event); - // }, (err) => { - // updateOnChange(err); - // }); - - // // Start watching - // for (const projectBuildContext of queue) { - // await projectBuildContext.watchFileChanges(); - // } } } @@ -328,9 +313,7 @@ class ProjectBuilder { // Build context exists // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) - // if (await projectBuildContext.requiresBuild()) { queue.push(projectBuildContext); - // } } }); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 06fef6322bf..963a92489e6 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -78,7 +78,6 @@ export default class CacheManager { } async getResourcePathForStage(buildSignature, stageId, resourcePath, integrity) { - // try { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } @@ -99,13 +98,6 @@ export default class CacheManager { return null; } return result.path; - // } catch (err) { - // if (err.code === "ENOENT") { - // // Cache miss - // return null; - // } - // throw err; - // } } async writeStage(buildSignature, stageId, resourcePath, buffer) { diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 42a76251679..7a8db0cef2f 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -127,17 +127,6 @@ export default class ProjectBuildCache { resourcesRead, resourcesWritten ); - // } else { - // log.verbose(`Initializing build cache for task ${taskName} in project ${this.#project.getName()}`); - // this.#taskCache.set(taskName, - // new BuildTaskCache(this.#project.getName(), taskName, { - // projectRequests: projectTrackingResults.requests, - // dependencyRequests: dependencyTrackingResults?.requests, - // resourcesRead, - // resourcesWritten - // }) - // ); - // } if (this.#invalidatedTasks.has(taskName)) { this.#invalidatedTasks.delete(taskName); @@ -306,35 +295,6 @@ export default class ProjectBuildCache { } async setTasks(taskNames) { - // if (this.#taskCache.size) { - // // If task cache entries already exist, validate they match the provided task names - // const existingTaskNames = Array.from(this.#taskCache.keys()); - // if (existingTaskNames.length !== taskNames.length || - // !existingTaskNames.every((taskName, idx) => taskName === taskNames[idx])) { - // throw new Error( - // `Cannot set tasks for project ${this.#project.getName()}: ` + - // `Existing cached tasks ${existingTaskNames.join(", ")} do not match ` + - // `provided task names ${taskNames.join(", ")}`); - // } - // return; - // } - // // Create task cache entries for all tasks and initialize stages - // for (const taskName of taskNames) { - // if (!this.#taskCache.has(taskName)) { - // this.#taskCache.set(taskName, - // new BuildTaskCache(this.#project.getName(), taskName, { - // projectRequests: [], - // dependencyRequests: [], - // resourcesRead: {}, - // resourcesWritten: {} - // }) - // ); - // this.#invalidatedTasks.set(taskName, { - // changedProjectResourcePaths: new Set(), - // changedDependencyResourcePaths: new Set(), - // }); - // } - // } const stageNames = taskNames.map((taskName) => this.#getStageNameForTask(taskName)); this.#project.setStages(stageNames); } @@ -415,21 +375,10 @@ export default class ProjectBuildCache { log.info(`Storing task outputs for project ${this.#project.getName()} in cache...`); return await Promise.all(this.#project.getStagesForCache().map(async ({stageId, reader}) => { - // if (!reader) { - // log.verbose( - // `Skipping serialization of empty writer for task ${taskName} in project ${this.#project.getName()}` - // ); - // return; - // } const resources = await reader.byGlob("/**/*"); const resourceMetadata = Object.create(null); await Promise.all(resources.map(async (res) => { // Store resource content in cacache via CacheManager - // const integrity = await this.#cacheManager.writeStageStream( - // this.#buildSignature, stageId, - // res.getOriginalPath(), await res.getStreamAsync() - // ); - // TODO: Decide whether to use stream or buffer const integrity = await this.#cacheManager.writeStage( this.#buildSignature, stageId, res.getOriginalPath(), await res.getBuffer() @@ -567,9 +516,6 @@ export default class ProjectBuildCache { async saveToDisk(buildManifest) { await this.#saveBuildManifest(buildManifest); - // await Promise.all([ - // await this.#saveCachedStages(); - // ]); } /** @@ -609,10 +555,6 @@ export default class ProjectBuildCache { `Restoring build cache for project ${this.#project.getName()} from build manifest ` + `with signature ${this.#buildSignature}`); - // for (const [taskName, metadata] of Object.entries(cache.taskMetadata)) { - // this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, metadata)); - // } - // Import task- and stage metadata first and in parallel await Promise.all([ this.#importCachedTasks(cache.taskMetadata), diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 8543e2294ce..90add986ec4 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -276,14 +276,6 @@ class Project extends Specification { // Use cached reader return reader; } - // const readers = []; - // this._addWriter(style, readers, this.getWriter()); - // readers.push(this._getStyledReader(style)); - // reader = createReaderCollectionPrioritized({ - // name: `Reader collection for project ${this.getName()}`, - // readers - // }); - // reader = this.#getReader(this.#currentStage, style); const readers = []; @@ -310,30 +302,10 @@ class Project extends Specification { return reader; } - // getCacheReader({style = "buildtime"} = {}) { - // return this.#getReader(this.#currentStage, style, true); - // } - getSourceReader(style = "buildtime") { return this._getStyledReader(style); } - // #getWriter() { - // return this.#currentStage.getWriter(); - // } - - // #createNewWriterStage(stageId) { - // const writer = this._createWriter(); - // this.#writers.set(stageId, writer); - // this.#currentWriter = writer; - - // // Invalidate dependents - // this.#currentWorkspace = null; - // this.#currentReader = new Map(); - - // return writer; - // } - /** * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a * project's resources. This is always of style buildtime. @@ -356,16 +328,6 @@ class Project extends Specification { return this.#currentStageWorkspace; } const writer = this.#currentStage.getWriter(); - - // if (this.#stageCacheReaders.has(this.getCurrentStage())) { - // reader = createReaderCollectionPrioritized({ - // name: `Reader collection for project ${this.getName()} stage ${this.getCurrentStage()}`, - // readers: [ - // this.#stageCacheReaders.get(this.getCurrentStage()), - // reader, - // ] - // }); - // } const workspace = createWorkspace({ reader: this.getReader(), writer: writer.collection || writer @@ -374,59 +336,6 @@ class Project extends Specification { return workspace; } - // getWorkspaceForVersion(version) { - // return createWorkspace({ - // reader: this.#getReader(version), - // writer: this.#writerVersions[version].collection || this.#writerVersions[version] - // }); - // } - - - // newVersion() { - // this.#workspaceSealed = false; - // this.#currentVersion++; - // this.useInitialStage(); - // } - - // revertToLastVersion() { - // if (this.#currentVersion === 0) { - // throw new Error(`Unable to revert to previous version: No previous version available`); - // } - // this.#currentVersion--; - // this.useInitialStage(); - - // // Remove writer version from all stages - // for (const writerVersions of this.#writers.values()) { - // if (writerVersions[this.#currentVersion]) { - // delete writerVersions[this.#currentVersion]; - // } - // } - // } - - // #getReader(style = "buildtime") { - // const readers = []; - - // // Add writers for previous stages as readers - // const stageIdx = this.#stages.findIndex((s) => s.getName() === stageId); - // if (stageIdx > 0) { // Stage 0 has no previous stage - // // Collect writers from all preceding stages - // for (let i = stageIdx - 1; i >= 0; i--) { - // const stageWriters = this.#getWriters(this.#stages[i], version, style); - // if (stageWriters) { - // readers.push(stageWriters); - // } - // } - // } - - // // Always add source reader - // readers.push(this._getStyledReader(style)); - - // return createReaderCollectionPrioritized({ - // name: `Reader collection for stage '${stage}' of project ${this.getName()}`, - // readers: readers - // }); - // } - useStage(stageId) { // if (newWriter && this.#writers.has(stageId)) { // this.#writers.delete(stageId); @@ -484,75 +393,6 @@ class Project extends Specification { }); } - // #getWriters(stage, version, style = "buildtime") { - // const readers = []; - // const stageWriters = this.#writers.get(stage); - // if (!stageWriters?.length) { - // return null; - // } - // for (let i = version; i >= 0; i--) { - // if (!stageWriters[i]) { - // // Writers is a sparse array, some stages might skip a version - // continue; - // } - // this._addWriter(style, readers, stageWriters[i]); - // } - - // return createReaderCollectionPrioritized({ - // name: `Collection of all writers for stage '${stage}', version ${version} of project ${this.getName()}`, - // readers - // }); - // } - - // getDeltaReader(stage) { - // const readers = []; - // const stageWriters = this.#writers.get(stage); - // if (!stageWriters?.length) { - // return null; - // } - // const version = this.#currentVersion; - // for (let i = version; i >= 1; i--) { // Skip version 0 (possibly containing cached writers) - // if (!stageWriters[i]) { - // // Writers is a sparse array, some stages might skip a version - // continue; - // } - // this._addWriter("buildtime", readers, stageWriters[i]); - // } - - // const reader = createReaderCollectionPrioritized({ - // name: `Collection of new writers for stage '${stage}', version ${version} of project ${this.getName()}`, - // readers - // }); - - - // // Condense writer versions (TODO: this step is optional but might improve memory consumption) - // // this.#condenseVersions(reader); - // return reader; - // } - - // #condenseVersions(reader) { - // for (const stage of this.#stages) { - // const stageWriters = this.#writers.get(stage); - // if (!stageWriters) { - // continue; - // } - // const condensedWriter = this._createWriter(); - - // for (let i = 1; i < stageWriters.length; i++) { - // if (stageWriters[i]) { - - // } - // } - - // // eslint-disable-next-line no-sparse-arrays - // const newWriters = [, condensedWriter]; - // if (stageWriters[0]) { - // newWriters[0] = stageWriters[0]; - // } - // this.#writers.set(stage, newWriters); - // } - // } - getStagesForCache() { return this.#stages.map((stage) => { const reader = this.#getReaderForStage(stage, "buildtime", false); @@ -586,57 +426,8 @@ class Project extends Specification { const newStage = new Stage(stageId, cacheReaders?.[i]); this.#stages.push(newStage); } - - // let lastIdx; - // for (let i = 0; i < stageIds.length; i++) { - // const stageId = stageIds[i]; - // const idx = this.#stages.findIndex((s) => { - // return s.getName() === stageId; - // }); - // if (idx !== -1) { - // // Stage already exists, remember its position for later use - // lastIdx = idx; - // continue; - // } - // const newStage = new Stage(stageId, cacheReaders?.[i]); - // if (lastIdx !== undefined) { - // // Insert new stage after the last existing one to maintain order - // this.#stages.splice(lastIdx + 1, 0, newStage); - // lastIdx++; - // } else { - // // Append new stage - // this.#stages.push(newStage); - // } - // } } - // /** - // * Import cached stages into the project - // * - // * @param {Array<{stageName: string, reader: import("@ui5/fs").Reader}>} stages Stages to import - // */ - // importCachedStages(stages) { - // if (!this.#workspaceSealed) { - // throw new Error(`Unable to import cached stages: Workspace is not sealed`); - // } - // for (const {stageName, reader} of stages) { - // if (!this.#stages.includes(stageName)) { - // this.#stages.push(stageName); - // } - // if (reader) { - // this.#writers.set(stageName, [reader]); - // } else { - // this.#writers.set(stageName, []); - // } - // } - // this.#currentVersion = 0; - // this.useFinalStage(); - // } - - // getCurrentStage() { - // return this.#currentStage; - // } - /* Overwritten in ComponentProject subclass */ _addWriter(style, readers, writer) { readers.push(writer); From 173a30b914961910d0e87d889ed5338733168185 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 16 Dec 2025 16:09:04 +0100 Subject: [PATCH 022/223] refactor(server): Cleanup obsolete code --- packages/server/lib/server.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 54ec6664276..e57d5b13bbc 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -2,7 +2,7 @@ import express from "express"; import portscanner from "portscanner"; import path from "node:path/posix"; import MiddlewareManager from "./middleware/MiddlewareManager.js"; -import {createAdapter, createReaderCollection} from "@ui5/fs/resourceFactory"; +import {createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; @@ -138,16 +138,8 @@ export async function serve(graph, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false }) { - // const rootReader = createAdapter({ - // virBasePath: "/", - // }); - // const dependencies = createAdapter({ - // virBasePath: "/", - // }); - const rootProject = graph.getRoot(); const watchHandler = await graph.build({ - cacheDir: path.join(rootProject.getRootPath(), ".ui5-cache"), includedDependencies: ["*"], watch: true, }); @@ -183,7 +175,7 @@ export async function serve(graph, { const resources = await createReaders(); - watchHandler.on("buildUpdated", async () => { + watchHandler.on("projectResourcesUpdated", async () => { const newResources = await createReaders(); // Patch resources resources.rootProject = newResources.rootProject; From 0f4a4d84f5a6d6927a6e2f292f701e2697044cae Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 16 Dec 2025 16:13:38 +0100 Subject: [PATCH 023/223] refactor(project): Rename watch handler events --- packages/project/lib/build/helpers/WatchHandler.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 0a5510a7eba..251b67a77fd 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -125,9 +125,9 @@ class WatchHandler extends EventEmitter { }); if (someProjectTasksInvalidated) { - this.emit("projectInvalidated"); + this.emit("projectResourcesInvalidated"); await this.#updateBuildResult(); - this.emit("buildUpdated"); + this.emit("projectResourcesUpdated"); } } } From 63b4c9a8fc39fcfd58f695804c8083a9b475125b Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 17 Dec 2025 09:06:43 +0100 Subject: [PATCH 024/223] refactor: Fix linting issues --- packages/builder/lib/tasks/escapeNonAsciiCharacters.js | 1 + packages/project/lib/graph/ProjectGraph.js | 5 ++++- packages/project/test/lib/build/TaskRunner.js | 3 ++- packages/server/lib/server.js | 1 - 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js index 73943c04a34..81d967c4012 100644 --- a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js +++ b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js @@ -14,6 +14,7 @@ import nonAsciiEscaper from "../processors/nonAsciiEscaper.js"; * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files + * @param {Array} parameters.invalidatedResources List of invalidated resource paths * @param {object} parameters.options Options * @param {string} parameters.options.pattern Glob pattern to locate the files to be processed * @param {string} parameters.options.encoding source file encoding either "UTF-8" or "ISO-8859-1" diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 0d15174e3b3..c673734d1de 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -632,6 +632,8 @@ class ProjectGraph { * @param {Array.} [parameters.excludedTasks=[]] List of tasks to be excluded. * @param {module:@ui5/project/build/ProjectBuilderOutputStyle} [parameters.outputStyle=Default] * Processes build results into a specific directory structure. + * @param {string} [parameters.cacheDir] Path to the cache directory + * @param {boolean} [parameters.watch] Whether to watch for file changes and re-execute the build automatically * @returns {Promise} Promise resolving to undefined once build has finished */ async build({ @@ -666,7 +668,8 @@ class ProjectGraph { destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, - cacheDir, watch, + // cacheDir, // FIXME/TODO: Not implemented yet + watch, }); } diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index a93b1eebc02..0db7a9514b5 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -296,7 +296,8 @@ test("_initTasks: Project of type 'library' (framework project)", async (t) => { test("_initTasks: Project of type 'theme-library'", async (t) => { const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("theme-library"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project: getMockProject("theme-library"), graph, taskUtil, taskRepository, + log: projectBuildLogger, cache, buildConfig }); await taskRunner._initTasks(); diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index e57d5b13bbc..e61906ffa6b 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,6 +1,5 @@ import express from "express"; import portscanner from "portscanner"; -import path from "node:path/posix"; import MiddlewareManager from "./middleware/MiddlewareManager.js"; import {createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; From a723fc35ba869bfd47d56ecfed367b8ba7791d87 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 17 Dec 2025 09:33:03 +0100 Subject: [PATCH 025/223] test(fs): Adjust getIntegrity tests --- packages/fs/lib/Resource.js | 2 +- packages/fs/lib/ResourceFacade.js | 8 +- packages/fs/test/lib/Resource.js | 260 +++++++++++++++++++++--------- 3 files changed, 189 insertions(+), 81 deletions(-) diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index 6cdc3a7531f..a60d8ca8970 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -539,7 +539,7 @@ class Resource { return this.#integrity; } if (this.isDirectory()) { - throw new Error(`Unable to calculate hash for directory resource: ${this.#path}`); + throw new Error(`Unable to calculate integrity for directory resource: ${this.#path}`); } // First wait for new content if the current content is flagged as drained diff --git a/packages/fs/lib/ResourceFacade.js b/packages/fs/lib/ResourceFacade.js index d9f8f41b5b1..fe549e7ac3c 100644 --- a/packages/fs/lib/ResourceFacade.js +++ b/packages/fs/lib/ResourceFacade.js @@ -190,8 +190,12 @@ class ResourceFacade { return this.#resource.setStream(stream); } - getHash() { - return this.#resource.getHash(); + getIntegrity() { + return this.#resource.getIntegrity(); + } + + getInode() { + return this.#resource.getInode(); } /** diff --git a/packages/fs/test/lib/Resource.js b/packages/fs/test/lib/Resource.js index 97f5d95cb44..d48b2828330 100644 --- a/packages/fs/test/lib/Resource.js +++ b/packages/fs/test/lib/Resource.js @@ -4,6 +4,7 @@ import {Stream, Transform} from "node:stream"; import {statSync, createReadStream} from "node:fs"; import {stat, readFile} from "node:fs/promises"; import path from "node:path"; +import ssri from "ssri"; import Resource from "../../lib/Resource.js"; function createBasicResource() { @@ -1571,35 +1572,42 @@ test("getSize", async (t) => { t.is(await resourceNoSize.getSize(), 91); }); -/* Hash Glossary +/* Integrity Glossary "Content" = "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" "New content" = "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=" */ -test("getHash: Throws error for directory resource", async (t) => { +test("getIntegrity: Throws error for directory resource", async (t) => { const resource = new Resource({ path: "/my/directory", isDirectory: true }); - await t.throwsAsync(resource.getHash(), { - message: "Unable to calculate hash for directory resource: /my/directory" + await t.throwsAsync(resource.getIntegrity(), { + message: "Unable to calculate integrity for directory resource: /my/directory" }); }); -test("getHash: Returns hash for buffer content", async (t) => { +test("getIntegrity: Returns integrity for buffer content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", buffer: Buffer.from("Content") }); - const hash = await resource.getHash(); - t.is(typeof hash, "string", "Hash is a string"); - t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); - t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash for content"); + const integrity = await resource.getIntegrity(); + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity for content"); }); -test("getHash: Returns hash for stream content", async (t) => { +test("getIntegrity: Returns integrity for stream content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", stream: new Stream.Readable({ @@ -1610,13 +1618,20 @@ test("getHash: Returns hash for stream content", async (t) => { }), }); - const hash = await resource.getHash(); - t.is(typeof hash, "string", "Hash is a string"); - t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); - t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash for content"); + const integrity = await resource.getIntegrity(); + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity for content"); }); -test("getHash: Returns hash for factory content", async (t) => { +test("getIntegrity: Returns integrity for factory content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", createStream: () => { @@ -1629,23 +1644,30 @@ test("getHash: Returns hash for factory content", async (t) => { } }); - const hash = await resource.getHash(); - t.is(typeof hash, "string", "Hash is a string"); - t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); - t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash for content"); + const integrity = await resource.getIntegrity(); + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity for content"); }); -test("getHash: Throws error for resource with no content", async (t) => { +test("getIntegrity: Throws error for resource with no content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource" }); - await t.throwsAsync(resource.getHash(), { + await t.throwsAsync(resource.getIntegrity(), { message: "Resource /my/path/to/resource has no content" }); }); -test("getHash: Different content produces different hashes", async (t) => { +test("getIntegrity: Different content produces different integrities", async (t) => { const resource1 = new Resource({ path: "/my/path/to/resource1", string: "Content 1" @@ -1656,13 +1678,13 @@ test("getHash: Different content produces different hashes", async (t) => { string: "Content 2" }); - const hash1 = await resource1.getHash(); - const hash2 = await resource2.getHash(); + const integrity1 = await resource1.getIntegrity(); + const integrity2 = await resource2.getIntegrity(); - t.not(hash1, hash2, "Different content produces different hashes"); + t.notDeepEqual(integrity1, integrity2, "Different content produces different integrities"); }); -test("getHash: Same content produces same hash", async (t) => { +test("getIntegrity: Same content produces same integrity", async (t) => { const resource1 = new Resource({ path: "/my/path/to/resource1", string: "Content" @@ -1683,31 +1705,40 @@ test("getHash: Same content produces same hash", async (t) => { }), }); - const hash1 = await resource1.getHash(); - const hash2 = await resource2.getHash(); - const hash3 = await resource3.getHash(); + const integrity1 = await resource1.getIntegrity(); + const integrity2 = await resource2.getIntegrity(); + const integrity3 = await resource3.getIntegrity(); - t.is(hash1, hash2, "Same content produces same hash for string and buffer content"); - t.is(hash1, hash3, "Same content produces same hash for string and stream"); + t.deepEqual(integrity1, integrity2, "Same content produces same integrity for string and buffer content"); + t.deepEqual(integrity1, integrity3, "Same content produces same integrity for string and stream"); }); -test("getHash: Waits for drained content", async (t) => { +test("getIntegrity: Waits for drained content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", string: "Initial content" }); // Drain the stream - await resource.getStream(); - const p1 = resource.getHash(); // Start getHash which should wait for new content + await resource.getStreamAsync(); + const p1 = resource.getIntegrity(); // Start getIntegrity which should wait for new content resource.setString("New content"); - const hash = await p1; - t.is(hash, "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=", "Correct hash for new content"); + const integrity = await p1; + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=", + options: [], + source: "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=" + } + ] + }), "Correct integrity for new content"); }); -test("getHash: Waits for content transformation to complete", async (t) => { +test("getIntegrity: Waits for content transformation to complete", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", stream: new Stream.Readable({ @@ -1721,31 +1752,50 @@ test("getHash: Waits for content transformation to complete", async (t) => { // Start getBuffer which will transform content const bufferPromise = resource.getBuffer(); - // Immediately call getHash while transformation is in progress - const hashPromise = resource.getHash(); + // Immediately call getIntegrity while transformation is in progress + const integrityPromise = resource.getIntegrity(); // Both should complete successfully await bufferPromise; - const hash = await hashPromise; - t.is(hash, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash after waiting for transformation"); + const integrity = await integrityPromise; + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity after waiting for transformation"); }); -test("getHash: Can be called multiple times on buffer content", async (t) => { +test("getIntegrity: Can be called multiple times on buffer content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", buffer: Buffer.from("Content") }); - const hash1 = await resource.getHash(); - const hash2 = await resource.getHash(); - const hash3 = await resource.getHash(); + const integrity1 = await resource.getIntegrity(); + const integrity2 = await resource.getIntegrity(); + const integrity3 = await resource.getIntegrity(); - t.is(hash1, hash2, "First and second hash are identical"); - t.is(hash2, hash3, "Second and third hash are identical"); - t.is(hash1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash value"); + t.deepEqual(integrity1, integrity2, "First and second integrity are identical"); + t.deepEqual(integrity2, integrity3, "Second and third integrity are identical"); + + t.deepEqual(integrity1, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity for content"); }); -test("getHash: Can be called multiple times on factory content", async (t) => { +test("getIntegrity: Can be called multiple times on factory content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", createStream: () => { @@ -1758,16 +1808,26 @@ test("getHash: Can be called multiple times on factory content", async (t) => { } }); - const hash1 = await resource.getHash(); - const hash2 = await resource.getHash(); - const hash3 = await resource.getHash(); + const integrity1 = await resource.getIntegrity(); + const integrity2 = await resource.getIntegrity(); + const integrity3 = await resource.getIntegrity(); - t.is(hash1, hash2, "First and second hash are identical"); - t.is(hash2, hash3, "Second and third hash are identical"); - t.is(hash1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash value"); + t.deepEqual(integrity1, integrity2, "First and second integrity are identical"); + t.deepEqual(integrity2, integrity3, "Second and third integrity are identical"); + + t.deepEqual(integrity1, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity for content"); }); -test("getHash: Can only be called once on stream content", async (t) => { +test("getIntegrity: Can be called multiple times on stream content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", stream: new Stream.Readable({ @@ -1778,52 +1838,96 @@ test("getHash: Can only be called once on stream content", async (t) => { }) }); - const hash1 = await resource.getHash(); - await t.throwsAsync(resource.getHash(), { - message: /Timeout waiting for content of Resource \/my\/path\/to\/resource to become available./ - }, `Threw with expected error message`); + const integrity1 = await resource.getIntegrity(); + const integrity2 = await resource.getIntegrity(); + const integrity3 = await resource.getIntegrity(); + + t.deepEqual(integrity1, integrity2, "First and second integrity are identical"); + t.deepEqual(integrity2, integrity3, "Second and third integrity are identical"); - t.is(hash1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", "Correct hash value"); + t.deepEqual(integrity1, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + options: [], + source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" + } + ] + }), "Correct integrity for content"); }); -test("getHash: Hash changes after content modification", async (t) => { +test("getIntegrity: Integrity changes after content modification", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", string: "Original content" }); - const hash1 = await resource.getHash(); - t.is(hash1, "sha256-OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=", "Correct hash for original content"); + const integrity1 = await resource.getIntegrity(); + t.deepEqual(integrity1, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=", + options: [], + source: "sha256-OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=" + } + ] + }), "Correct integrity for original content"); resource.setString("Modified content"); - const hash2 = await resource.getHash(); - t.is(hash2, "sha256-8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=", "Hash changes after modification"); - t.not(hash1, hash2, "New hash is different from original"); + const integrity2 = await resource.getIntegrity(); + t.deepEqual(integrity2, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=", + options: [], + source: "sha256-8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=" + } + ] + }), "Integrity changes after content modification"); + t.notDeepEqual(integrity1, integrity2, "New integrity is different from original"); }); -test("getHash: Works with empty content", async (t) => { +test("getIntegrity: Works with empty content", async (t) => { const resource = new Resource({ path: "/my/path/to/resource", string: "" }); - const hash = await resource.getHash(); - t.is(typeof hash, "string", "Hash is a string"); - t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); - t.is(hash, "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "Correct hash for empty content"); + const integrity = await resource.getIntegrity(); + + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", + options: [], + source: "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + } + ] + }), "Correct integrity for empty content"); }); -test("getHash: Works with large content", async (t) => { +test("getIntegrity: Works with large content", async (t) => { const largeContent = "x".repeat(1024 * 1024); // 1MB of 'x' const resource = new Resource({ path: "/my/path/to/resource", string: largeContent }); - const hash = await resource.getHash(); - t.is(typeof hash, "string", "Hash is a string"); - t.true(hash.startsWith("sha256-"), "Hash starts with sha256-"); - // Hash of 1MB of 'x' characters - t.is(hash, "sha256-j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=", "Correct hash for large content"); + const integrity = await resource.getIntegrity(); + + t.deepEqual(integrity, ssri.parse({ + sha256: [ + { + algorithm: "sha256", + digest: "j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=", + options: [], + source: "sha256-j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=" + } + ] + }), "Correct integrity for large content"); }); From 7e46650c2b98dab191d00fdd712df3ffa1a11ed4 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 17 Dec 2025 13:21:41 +0100 Subject: [PATCH 026/223] refactor: Integrity handling getIntegrity tests still need to be updated --- packages/fs/lib/Resource.js | 6 +++--- packages/project/lib/build/cache/CacheManager.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index a60d8ca8970..f3642c69e4d 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -554,15 +554,15 @@ class Resource { switch (this.#contentType) { case CONTENT_TYPES.BUFFER: - this.#integrity = ssri.fromData(this.#content, SSRI_OPTIONS); + this.#integrity = ssri.fromData(this.#content, SSRI_OPTIONS).toString(); break; case CONTENT_TYPES.FACTORY: - this.#integrity = await ssri.fromStream(this.#createStreamFactory(), SSRI_OPTIONS); + this.#integrity = (await ssri.fromStream(this.#createStreamFactory(), SSRI_OPTIONS)).toString(); break; case CONTENT_TYPES.STREAM: // To be discussed: Should we read the stream into a buffer here (using #getBufferFromStream) to avoid // draining it? - this.#integrity = ssri.fromData(await this.#getBufferFromStream(this.#content), SSRI_OPTIONS); + this.#integrity = ssri.fromData(await this.#getBufferFromStream(this.#content), SSRI_OPTIONS).toString(); break; case CONTENT_TYPES.DRAINED_STREAM: throw new Error(`Unexpected error: Content of Resource ${this.#path} is flagged as drained.`); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 963a92489e6..f39e7ac0541 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -87,10 +87,10 @@ export default class CacheManager { log.info(`Integrity mismatch for cache entry ` + `${cacheKey}: expected ${integrity}, got ${result.integrity}`); - const res = await cacache.get.byDigest(this.#casDir, result.integrity); + const res = await cacache.get.byDigest(this.#casDir, integrity); if (res) { log.info(`Updating cache entry with expectation...`); - await this.writeStage(buildSignature, stageId, resourcePath, res.data); + await this.writeStage(buildSignature, stageId, resourcePath, res); return await this.getResourcePathForStage(buildSignature, stageId, resourcePath, integrity); } } From a2472fc01efbfd3737e061e53cb1daf2f53dea8d Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 17 Dec 2025 13:47:30 +0100 Subject: [PATCH 027/223] test(fs): Adjust getIntegrity tests again --- packages/fs/test/lib/Resource.js | 165 +++++++------------------------ 1 file changed, 34 insertions(+), 131 deletions(-) diff --git a/packages/fs/test/lib/Resource.js b/packages/fs/test/lib/Resource.js index d48b2828330..aa0d64fa507 100644 --- a/packages/fs/test/lib/Resource.js +++ b/packages/fs/test/lib/Resource.js @@ -4,7 +4,6 @@ import {Stream, Transform} from "node:stream"; import {statSync, createReadStream} from "node:fs"; import {stat, readFile} from "node:fs/promises"; import path from "node:path"; -import ssri from "ssri"; import Resource from "../../lib/Resource.js"; function createBasicResource() { @@ -1595,16 +1594,8 @@ test("getIntegrity: Returns integrity for buffer content", async (t) => { }); const integrity = await resource.getIntegrity(); - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity for content"); + t.is(integrity, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity for content"); }); test("getIntegrity: Returns integrity for stream content", async (t) => { @@ -1619,16 +1610,8 @@ test("getIntegrity: Returns integrity for stream content", async (t) => { }); const integrity = await resource.getIntegrity(); - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity for content"); + t.is(integrity, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity for content"); }); test("getIntegrity: Returns integrity for factory content", async (t) => { @@ -1645,16 +1628,8 @@ test("getIntegrity: Returns integrity for factory content", async (t) => { }); const integrity = await resource.getIntegrity(); - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity for content"); + t.is(integrity, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity for content"); }); test("getIntegrity: Throws error for resource with no content", async (t) => { @@ -1681,7 +1656,7 @@ test("getIntegrity: Different content produces different integrities", async (t) const integrity1 = await resource1.getIntegrity(); const integrity2 = await resource2.getIntegrity(); - t.notDeepEqual(integrity1, integrity2, "Different content produces different integrities"); + t.not(integrity1, integrity2, "Different content produces different integrities"); }); test("getIntegrity: Same content produces same integrity", async (t) => { @@ -1709,8 +1684,8 @@ test("getIntegrity: Same content produces same integrity", async (t) => { const integrity2 = await resource2.getIntegrity(); const integrity3 = await resource3.getIntegrity(); - t.deepEqual(integrity1, integrity2, "Same content produces same integrity for string and buffer content"); - t.deepEqual(integrity1, integrity3, "Same content produces same integrity for string and stream"); + t.is(integrity1, integrity2, "Same content produces same integrity for string and buffer content"); + t.is(integrity1, integrity3, "Same content produces same integrity for string and stream"); }); test("getIntegrity: Waits for drained content", async (t) => { @@ -1726,16 +1701,8 @@ test("getIntegrity: Waits for drained content", async (t) => { resource.setString("New content"); const integrity = await p1; - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=", - options: [], - source: "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=" - } - ] - }), "Correct integrity for new content"); + t.is(integrity, "sha256-EvQbHDId8MgpzlgZllZv3lKvbK/h0qDHRmzeU+bxPMo=", + "Correct integrity for new content"); }); test("getIntegrity: Waits for content transformation to complete", async (t) => { @@ -1758,16 +1725,8 @@ test("getIntegrity: Waits for content transformation to complete", async (t) => // Both should complete successfully await bufferPromise; const integrity = await integrityPromise; - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity after waiting for transformation"); + t.is(integrity, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity after waiting for transformation"); }); test("getIntegrity: Can be called multiple times on buffer content", async (t) => { @@ -1780,19 +1739,11 @@ test("getIntegrity: Can be called multiple times on buffer content", async (t) = const integrity2 = await resource.getIntegrity(); const integrity3 = await resource.getIntegrity(); - t.deepEqual(integrity1, integrity2, "First and second integrity are identical"); - t.deepEqual(integrity2, integrity3, "Second and third integrity are identical"); + t.is(integrity1, integrity2, "First and second integrity are identical"); + t.is(integrity2, integrity3, "Second and third integrity are identical"); - t.deepEqual(integrity1, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity for content"); + t.is(integrity1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity for content"); }); test("getIntegrity: Can be called multiple times on factory content", async (t) => { @@ -1812,19 +1763,11 @@ test("getIntegrity: Can be called multiple times on factory content", async (t) const integrity2 = await resource.getIntegrity(); const integrity3 = await resource.getIntegrity(); - t.deepEqual(integrity1, integrity2, "First and second integrity are identical"); - t.deepEqual(integrity2, integrity3, "Second and third integrity are identical"); + t.is(integrity1, integrity2, "First and second integrity are identical"); + t.is(integrity2, integrity3, "Second and third integrity are identical"); - t.deepEqual(integrity1, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity for content"); + t.is(integrity1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity for content"); }); test("getIntegrity: Can be called multiple times on stream content", async (t) => { @@ -1842,19 +1785,11 @@ test("getIntegrity: Can be called multiple times on stream content", async (t) = const integrity2 = await resource.getIntegrity(); const integrity3 = await resource.getIntegrity(); - t.deepEqual(integrity1, integrity2, "First and second integrity are identical"); - t.deepEqual(integrity2, integrity3, "Second and third integrity are identical"); + t.is(integrity1, integrity2, "First and second integrity are identical"); + t.is(integrity2, integrity3, "Second and third integrity are identical"); - t.deepEqual(integrity1, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", - options: [], - source: "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=" - } - ] - }), "Correct integrity for content"); + t.is(integrity1, "sha256-R70pB1+LgBnwvuxthr7afJv2eq8FBT3L4LO8tjloUX8=", + "Correct integrity for content"); }); test("getIntegrity: Integrity changes after content modification", async (t) => { @@ -1864,31 +1799,15 @@ test("getIntegrity: Integrity changes after content modification", async (t) => }); const integrity1 = await resource.getIntegrity(); - t.deepEqual(integrity1, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=", - options: [], - source: "sha256-OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=" - } - ] - }), "Correct integrity for original content"); + t.is(integrity1, "sha256-OUni2q0Lopc2NkTnXeaaYPNQJNUATQtbAqMWJvtCVNo=", + "Correct integrity for original content"); resource.setString("Modified content"); const integrity2 = await resource.getIntegrity(); - t.deepEqual(integrity2, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=", - options: [], - source: "sha256-8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=" - } - ] - }), "Integrity changes after content modification"); - t.notDeepEqual(integrity1, integrity2, "New integrity is different from original"); + t.is(integrity2, "sha256-8fba0TDG5CusKMUf/7GVTTxaYjVbRXacQv2lt3RdtT8=", + "Integrity changes after content modification"); + t.not(integrity1, integrity2, "New integrity is different from original"); }); test("getIntegrity: Works with empty content", async (t) => { @@ -1899,16 +1818,8 @@ test("getIntegrity: Works with empty content", async (t) => { const integrity = await resource.getIntegrity(); - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", - options: [], - source: "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" - } - ] - }), "Correct integrity for empty content"); + t.is(integrity, "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", + "Correct integrity for empty content"); }); test("getIntegrity: Works with large content", async (t) => { @@ -1920,14 +1831,6 @@ test("getIntegrity: Works with large content", async (t) => { const integrity = await resource.getIntegrity(); - t.deepEqual(integrity, ssri.parse({ - sha256: [ - { - algorithm: "sha256", - digest: "j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=", - options: [], - source: "sha256-j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=" - } - ] - }), "Correct integrity for large content"); + t.is(integrity, "sha256-j5kLoLV3tRzwCeoEk2jBa72hsh4bk74HqCR1i7JTw5s=", + "Correct integrity for large content"); }); From 97ac058ef86db357d8d447d80a30f6964031fb0f Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 17 Dec 2025 13:53:56 +0100 Subject: [PATCH 028/223] refactor: Consider npm-shrinkwrap.json --- packages/project/lib/build/helpers/calculateBuildSignature.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/project/lib/build/helpers/calculateBuildSignature.js b/packages/project/lib/build/helpers/calculateBuildSignature.js index 620c3523715..a64e05e842a 100644 --- a/packages/project/lib/build/helpers/calculateBuildSignature.js +++ b/packages/project/lib/build/helpers/calculateBuildSignature.js @@ -40,8 +40,12 @@ async function getVersion(pkg) { async function getLockfileHash(project) { const rootReader = project.getRootReader({useGitIgnore: false}); const lockfiles = await Promise.all([ + // npm await rootReader.byPath("/package-lock.json"), + await rootReader.byPath("/npm-shrinkwrap.json"), + // Yarn await rootReader.byPath("/yarn.lock"), + // pnpm await rootReader.byPath("/pnpm-lock.yaml"), ]); let hash = ""; From b727c9bece56a20f89029d8c2bb3fc37c90c0a2b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 13:51:11 +0100 Subject: [PATCH 029/223] refactor: Rename Tracker => MonitoredReader --- packages/fs/lib/{Tracker.js => MonitoredReader.js} | 2 +- .../lib/{DuplexTracker.js => MonitoredReaderWriter.js} | 4 +--- packages/fs/lib/resourceFactory.js | 10 +++++----- packages/project/lib/build/TaskRunner.js | 6 +++--- packages/project/lib/build/cache/ProjectBuildCache.js | 10 +++++----- 5 files changed, 15 insertions(+), 17 deletions(-) rename packages/fs/lib/{Tracker.js => MonitoredReader.js} (96%) rename packages/fs/lib/{DuplexTracker.js => MonitoredReaderWriter.js} (95%) diff --git a/packages/fs/lib/Tracker.js b/packages/fs/lib/MonitoredReader.js similarity index 96% rename from packages/fs/lib/Tracker.js rename to packages/fs/lib/MonitoredReader.js index ed19019e364..db2acaf4c8d 100644 --- a/packages/fs/lib/Tracker.js +++ b/packages/fs/lib/MonitoredReader.js @@ -1,6 +1,6 @@ import AbstractReader from "./AbstractReader.js"; -export default class Trace extends AbstractReader { +export default class MonitoredReader extends AbstractReader { #reader; #sealed = false; #pathsRead = []; diff --git a/packages/fs/lib/DuplexTracker.js b/packages/fs/lib/MonitoredReaderWriter.js similarity index 95% rename from packages/fs/lib/DuplexTracker.js rename to packages/fs/lib/MonitoredReaderWriter.js index 2ccdb56b1a4..22b46c12b79 100644 --- a/packages/fs/lib/DuplexTracker.js +++ b/packages/fs/lib/MonitoredReaderWriter.js @@ -1,8 +1,6 @@ import AbstractReaderWriter from "./AbstractReaderWriter.js"; -// TODO: Alternative name: Inspector/Interceptor/... - -export default class Trace extends AbstractReaderWriter { +export default class MonitoredReaderWriter extends AbstractReaderWriter { #readerWriter; #sealed = false; #pathsRead = []; diff --git a/packages/fs/lib/resourceFactory.js b/packages/fs/lib/resourceFactory.js index 51b4f8a60df..cfa27fd7bc5 100644 --- a/packages/fs/lib/resourceFactory.js +++ b/packages/fs/lib/resourceFactory.js @@ -10,8 +10,8 @@ import WriterCollection from "./WriterCollection.js"; import Filter from "./readers/Filter.js"; import Link from "./readers/Link.js"; import Proxy from "./readers/Proxy.js"; -import Tracker from "./Tracker.js"; -import DuplexTracker from "./DuplexTracker.js"; +import MonitoredReader from "./MonitoredReader.js"; +import MonitoredReaderWriter from "./MonitoredReaderWriter.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("resources:resourceFactory"); @@ -278,11 +278,11 @@ export function createFlatReader({name, reader, namespace}) { }); } -export function createTracker(readerWriter) { +export function createMonitor(readerWriter) { if (readerWriter instanceof DuplexCollection) { - return new DuplexTracker(readerWriter); + return new MonitoredReaderWriter(readerWriter); } - return new Tracker(readerWriter); + return new MonitoredReader(readerWriter); } /** diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index a88f1f69409..dd874116768 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -1,6 +1,6 @@ import {getLogger} from "@ui5/logger"; import composeTaskList from "./helpers/composeTaskList.js"; -import {createReaderCollection, createTracker} from "@ui5/fs/resourceFactory"; +import {createReaderCollection, createMonitor} from "@ui5/fs/resourceFactory"; /** * TaskRunner @@ -204,7 +204,7 @@ class TaskRunner { this._log.info( `Executing task ${taskName} for project ${this._project.getName()}`); - const workspace = createTracker(this._project.getWorkspace()); + const workspace = createMonitor(this._project.getWorkspace()); const params = { workspace, taskUtil: this._taskUtil, @@ -225,7 +225,7 @@ class TaskRunner { let dependencies; if (requiresDependencies) { - dependencies = createTracker(this._allDependenciesReader); + dependencies = createMonitor(this._allDependenciesReader); params.dependencies = dependencies; } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 7a8db0cef2f..bda0968d886 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -49,13 +49,13 @@ export default class ProjectBuildCache { * * @param {string} taskName Name of the executed task * @param {Set|undefined} expectedOutput Expected output resource paths - * @param {object} workspaceTracker Tracker that monitored workspace reads - * @param {object} [dependencyTracker] Tracker that monitored dependency reads + * @param {object} workspaceMonitor Tracker that monitored workspace reads + * @param {object} [dependencyMonitor] Tracker that monitored dependency reads * @returns {Promise} */ - async recordTaskResult(taskName, expectedOutput, workspaceTracker, dependencyTracker) { - const projectTrackingResults = workspaceTracker.getResults(); - const dependencyTrackingResults = dependencyTracker?.getResults(); + async recordTaskResult(taskName, expectedOutput, workspaceMonitor, dependencyMonitor) { + const projectTrackingResults = workspaceMonitor.getResults(); + const dependencyTrackingResults = dependencyMonitor?.getResults(); const resourcesRead = projectTrackingResults.resourcesRead; if (dependencyTrackingResults) { From 6f44cc6172b5f57fc4a5f2cd55408b15eac1380b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 13:51:49 +0100 Subject: [PATCH 030/223] refactor(project): Use workspace version in stage name --- packages/project/lib/specifications/Project.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 90add986ec4..5ed61de2a9e 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -20,6 +20,7 @@ class Project extends Specification { #currentStage; #currentStageReadIndex = -1; #currentStageName = ""; + #workspaceVersion = 0; constructor(parameters) { super(parameters); @@ -370,8 +371,9 @@ class Project extends Specification { * */ sealWorkspace() { + this.#workspaceVersion++; this.#currentStage = null; // Unset stage - This blocks further getWorkspace() calls - this.#currentStageName = ""; + this.#currentStageName = ``; this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages // Unset "current" reader/writer. They will be recreated on demand From e0d300a0ed81b7eadf64e9887fb7fb6a4ab3fb06 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 14:09:29 +0100 Subject: [PATCH 031/223] refactor(project): Fix stage writer order --- packages/project/lib/specifications/Project.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 5ed61de2a9e..3a50a1382ab 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -462,7 +462,7 @@ class Project extends Specification { class Stage { #id; - #writerVersions = []; + #writerVersions = []; // First element is the latest writer #cacheReader; constructor(id, cacheReader) { @@ -475,16 +475,16 @@ class Stage { } newVersion(writer) { - this.#writerVersions.push(writer); + this.#writerVersions.unshift(writer); } getWriter() { - return this.#writerVersions[this.#writerVersions.length - 1]; + return this.#writerVersions[0]; } getAllWriters(includeCache = true) { if (includeCache && this.#cacheReader) { - return [this.#cacheReader, ...this.#writerVersions]; + return [...this.#writerVersions, this.#cacheReader]; } return this.#writerVersions; } From 35a562f3ee455bf8c8938c0d5a3d294ac26d6bed Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 16:27:18 +0100 Subject: [PATCH 032/223] refactor(fs): Add Switch reader --- packages/fs/lib/readers/Switch.js | 82 ++++++++++++++++++++++++++++++ packages/fs/lib/resourceFactory.js | 14 +++++ 2 files changed, 96 insertions(+) create mode 100644 packages/fs/lib/readers/Switch.js diff --git a/packages/fs/lib/readers/Switch.js b/packages/fs/lib/readers/Switch.js new file mode 100644 index 00000000000..ed2bf2cca83 --- /dev/null +++ b/packages/fs/lib/readers/Switch.js @@ -0,0 +1,82 @@ +import AbstractReader from "../AbstractReader.js"; + +/** + * Reader allowing to switch its underlying reader at runtime. + * If no reader is set, read operations will be halted/paused until a reader is set. + */ +export default class Switch extends AbstractReader { + #reader; + #pendingCalls = []; + + constructor({name, reader}) { + super(name); + this.#reader = reader; + } + + /** + * Sets the underlying reader and processes any pending read operations. + * + * @param {@ui5/fs/AbstractReader} reader The reader to delegate to. + */ + setReader(reader) { + this.#reader = reader; + this._processPendingCalls(); + } + + /** + * Unsets the underlying reader. Future calls will be queued. + */ + unsetReader() { + this.#reader = null; + } + + async _byGlob(virPattern, options, trace) { + if (this.#reader) { + return this.#reader._byGlob(virPattern, options, trace); + } + + // No reader set, so we queue the call and return a pending promise + return this._enqueueCall("_byGlob", [virPattern, options, trace]); + } + + + async _byPath(virPath, options, trace) { + if (this.#reader) { + return this.#reader._byPath(virPath, options, trace); + } + + // No reader set, so we queue the call and return a pending promise + return this._enqueueCall("_byPath", [virPath, options, trace]); + } + + /** + * Queues a method call by returning a promise and storing its resolver. + * + * @param {string} methodName The method name to call later. + * @param {Array} args The arguments to pass to the method. + * @returns {Promise} A promise that will be resolved/rejected when the call is processed. + */ + _enqueueCall(methodName, args) { + return new Promise((resolve, reject) => { + this.#pendingCalls.push({methodName, args, resolve, reject}); + }); + } + + /** + * Processes all pending calls in the queue using the current reader. + * + * @private + */ + _processPendingCalls() { + const callsToProcess = this.#pendingCalls; + this.#pendingCalls = []; // Clear queue immediately to prevent race conditions + + for (const call of callsToProcess) { + const {methodName, args, resolve, reject} = call; + // Execute the pending call with the newly set reader + this.#reader[methodName](...args) + .then(resolve) + .catch(reject); + } + } +} diff --git a/packages/fs/lib/resourceFactory.js b/packages/fs/lib/resourceFactory.js index cfa27fd7bc5..cbd7227e62d 100644 --- a/packages/fs/lib/resourceFactory.js +++ b/packages/fs/lib/resourceFactory.js @@ -10,6 +10,7 @@ import WriterCollection from "./WriterCollection.js"; import Filter from "./readers/Filter.js"; import Link from "./readers/Link.js"; import Proxy from "./readers/Proxy.js"; +import Switch from "./readers/Switch.js"; import MonitoredReader from "./MonitoredReader.js"; import MonitoredReaderWriter from "./MonitoredReaderWriter.js"; import {getLogger} from "@ui5/logger"; @@ -278,6 +279,19 @@ export function createFlatReader({name, reader, namespace}) { }); } +export function createSwitch({name, reader}) { + return new Switch({ + name, + reader: reader, + }); +} + +/** + * Creates a monitored reader or reader-writer depending on the provided instance + * of the given readerWriter. + * + * @param {@ui5/fs/AbstractReader|@ui5/fs/AbstractReaderWriter} readerWriter Reader or ReaderWriter to monitor + */ export function createMonitor(readerWriter) { if (readerWriter instanceof DuplexCollection) { return new MonitoredReaderWriter(readerWriter); From cfefec858e429375e85e35f6e29cbbf7e09f6cbc Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 16:27:50 +0100 Subject: [PATCH 033/223] refactor(project): Cleanup WatchHandler debounce --- .../project/lib/build/helpers/WatchHandler.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 251b67a77fd..860256f3b47 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -65,7 +65,7 @@ class WatchHandler extends EventEmitter { } async #fileChanged(project, filePath) { - // Collect changes (grouped by project), then trigger callbacks (debounced) + // Collect changes (grouped by project), then trigger callbacks const resourcePath = project.getVirtualPath(filePath); if (!this.#sourceChanges.has(project)) { this.#sourceChanges.set(project, new Set()); @@ -73,18 +73,13 @@ class WatchHandler extends EventEmitter { this.#sourceChanges.get(project).add(resourcePath); // Trigger callbacks debounced - if (!this.#fileChangeHandlerTimeout) { - this.#fileChangeHandlerTimeout = setTimeout(async () => { - await this.#handleResourceChanges(); - this.#fileChangeHandlerTimeout = null; - }, 100); - } else { + if (this.#fileChangeHandlerTimeout) { clearTimeout(this.#fileChangeHandlerTimeout); - this.#fileChangeHandlerTimeout = setTimeout(async () => { - await this.#handleResourceChanges(); - this.#fileChangeHandlerTimeout = null; - }, 100); } + this.#fileChangeHandlerTimeout = setTimeout(async () => { + await this.#handleResourceChanges(); + this.#fileChangeHandlerTimeout = null; + }, 100); } async #handleResourceChanges() { From 5d76e8d30cca88c5fe34aeb02c4e30635051208f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 16:28:20 +0100 Subject: [PATCH 034/223] refactor(project): Fix outdated API call --- packages/project/lib/build/helpers/ProjectBuildContext.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 547f85076e5..038f25bc638 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -176,7 +176,7 @@ class ProjectBuildContext { // Propagate changes to all dependents of the project for (const {project: dep} of graph.traverseDependents(this._project.getName())) { const projectBuildContext = this._buildContext.getBuildContext(dep.getName()); - projectBuildContext.getBuildCache().this.markResourcesChanged(emptySet, updatedResourcePaths); + projectBuildContext.getBuildCache().resourceChanged(emptySet, updatedResourcePaths); } } From f0575f2596a4c55a1cec4b532484aae44a0074f6 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 17:18:57 +0100 Subject: [PATCH 035/223] refactor(project): Fix build signature calculation --- .../build/helpers/calculateBuildSignature.js | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/project/lib/build/helpers/calculateBuildSignature.js b/packages/project/lib/build/helpers/calculateBuildSignature.js index a64e05e842a..6ca04986bf0 100644 --- a/packages/project/lib/build/helpers/calculateBuildSignature.js +++ b/packages/project/lib/build/helpers/calculateBuildSignature.js @@ -40,6 +40,7 @@ async function getVersion(pkg) { async function getLockfileHash(project) { const rootReader = project.getRootReader({useGitIgnore: false}); const lockfiles = await Promise.all([ + // TODO: Search upward for lockfiles in parent directories? // npm await rootReader.byPath("/package-lock.json"), await rootReader.byPath("/npm-shrinkwrap.json"), @@ -59,18 +60,43 @@ async function getLockfileHash(project) { } function collectDepInfo(graph, project) { - const projects = Object.create(null); + let projects = []; for (const depName of graph.getTransitiveDependencies(project.getName())) { const dep = graph.getProject(depName); - projects[depName] = { + projects.push({ + name: dep.getName(), version: dep.getVersion() - }; + }); } - const extensions = Object.create(null); - for (const extension of graph.getExtensions()) { - extensions[extension.getName()] = { - version: extension.getVersion() - }; + projects = projects.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + + // Collect relevant extensions + let extensions = []; + if (graph.getRoot() === project) { + // Custom middleware is only relevant for root project + project.getCustomMiddleware().forEach((middlewareDef) => { + const extension = graph.getExtension(middlewareDef.name); + if (extension) { + extensions.push({ + name: extension.getName(), + version: extension.getVersion() + }); + } + }); } + project.getCustomTasks().forEach((taskDef) => { + const extension = graph.getExtension(taskDef.name); + if (extension) { + extensions.push({ + name: extension.getName(), + version: extension.getVersion() + }); + } + }); + extensions = extensions.sort((a, b) => { + return a.name.localeCompare(b.name); + }); return {projects, extensions}; } From 265260ad8826841d2f7c8b264b0d0b2dc15e4531 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 21:11:56 +0100 Subject: [PATCH 036/223] refactor(fs): Pass integrity to cloned resource --- packages/fs/lib/Resource.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index f3642c69e4d..451580d308f 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -786,6 +786,7 @@ class Resource { isDirectory: this.#isDirectory, byteSize: this.#byteSize, lastModified: this.#lastModified, + integrity: this.#integrity, sourceMetadata: clone(this.#sourceMetadata) }; From 47f6e70abfe9f9407598aed06f5fbd7093f24c7c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 17 Dec 2025 21:12:56 +0100 Subject: [PATCH 037/223] refactor(project): Fix pattern matching and resource comparison --- packages/project/lib/build/cache/BuildTaskCache.js | 6 +++--- .../project/lib/build/cache/ProjectBuildCache.js | 4 ++-- packages/project/lib/build/cache/utils.js | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index c5ad3785a87..7f1649e953f 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -188,7 +188,7 @@ export default class BuildTaskCache { * @param {object} resource - Resource instance to check * @returns {Promise} True if resource is in cache with matching content */ - async hasResourceInReadCache(resource) { + async matchResourceInReadCache(resource) { const cachedResource = this.#resourcesRead[resource.getPath()]; if (!cachedResource) { return false; @@ -206,7 +206,7 @@ export default class BuildTaskCache { * @param {object} resource - Resource instance to check * @returns {Promise} True if resource is in cache with matching content */ - async hasResourceInWriteCache(resource) { + async matchResourceInWriteCache(resource) { const cachedResource = this.#resourcesWritten[resource.getPath()]; if (!cachedResource) { return false; @@ -223,7 +223,7 @@ export default class BuildTaskCache { if (pathsRead.includes(resourcePath)) { return true; } - if (patterns.length && micromatch.isMatch(resourcePath, patterns)) { + if (patterns.length && micromatch(resourcePath, patterns).length > 0) { return true; } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index bda0968d886..ffcf3f059a4 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -81,7 +81,7 @@ export default class ProjectBuildCache { const changedPaths = new Set((await Promise.all(writtenResourcePaths .map(async (resourcePath) => { // Check whether resource content actually changed - if (await taskCache.hasResourceInWriteCache(resourcesWritten[resourcePath])) { + if (await taskCache.matchResourceInWriteCache(resourcesWritten[resourcePath])) { return undefined; } return resourcePath; @@ -224,7 +224,7 @@ export default class ProjectBuildCache { if (!taskCache) { throw new Error(`Failed to validate changed resources for task ${taskName}: Task cache not found`); } - if (await taskCache.hasResourceInReadCache(resource)) { + if (await taskCache.matchResourceInReadCache(resource)) { log.verbose(`Resource content has not changed for task ${taskName}, ` + `removing ${resourcePath} from set of changed resource paths`); changedResourcePaths.delete(resourcePath); diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index cd45c9f3444..de32b3f39a7 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -24,15 +24,15 @@ export async function areResourcesEqual(resourceA, resourceB) { if (resourceA.getOriginalPath() !== resourceB.getOriginalPath()) { throw new Error("Cannot compare resources with different original paths"); } - if (resourceA.getLastModified() !== resourceB.getLastModified()) { - return false; + if (resourceA.getLastModified() === resourceB.getLastModified()) { + return true; + } + if (await resourceA.getSize() === await resourceB.getSize()) { + return true; } - if (await resourceA.getSize() !== resourceB.getSize()) { - return false; + if (await resourceA.getIntegrity() === await resourceB.getIntegrity()) { + return true; } - // if (await resourceA.getString() === await resourceB.getString()) { - // return true; - // } return false; } From b88a43149d611c55149070f517269bbf5315859e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 18 Dec 2025 09:51:42 +0100 Subject: [PATCH 038/223] refactor(project): Import/overwrite stages from cache after saving --- .../lib/build/cache/ProjectBuildCache.js | 4 ++- .../project/lib/specifications/Project.js | 28 ++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index ffcf3f059a4..29fce6b459b 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -365,6 +365,9 @@ export default class ProjectBuildCache { await this.#cacheManager.writeBuildManifest( this.#project, this.#buildSignature, buildManifest); + + // Import cached stages back into project to prevent inconsistent state during next build/save + await this.#importCachedStages(buildManifest.cache.stages); } #getStageNameForTask(taskName) { @@ -392,7 +395,6 @@ export default class ProjectBuildCache { })); return [stageId, resourceMetadata]; })); - // Optional TODO: Re-import cache as base layer to reduce memory pressure? } async #checkForIndexChanges(index, indexTimestamp) { diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 3a50a1382ab..d1035ee02e6 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -381,6 +381,16 @@ class Project extends Specification { this.#currentStageWorkspace = null; } + _resetStages() { + this.#stages = []; + this.#currentStage = null; + this.#currentStageName = ""; + this.#currentStageReadIndex = -1; + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + this.#workspaceVersion = 0; + } + #getReaderForStage(stage, style = "buildtime", includeCache = true) { const writers = stage.getAllWriters(includeCache); const readers = []; @@ -406,23 +416,7 @@ class Project extends Specification { } setStages(stageIds, cacheReaders) { - if (this.#stages.length > 0) { - // Stages have already been set. Compare existing stages with new ones and throw on mismatch - for (let i = 0; i < stageIds.length; i++) { - const stageId = stageIds[i]; - if (this.#stages[i].getId() !== stageId) { - throw new Error( - `Unable to set stages for project ${this.getName()}: Stage mismatch at position ${i} ` + - `(existing: ${this.#stages[i].getId()}, new: ${stageId})`); - } - } - if (cacheReaders?.length) { - throw new Error( - `Unable to set stages for project ${this.getName()}: Cache readers can only be set ` + - `when stages are created for the first time`); - } - return; // Stages already set and matching, no further processing needed - } + this._resetStages(); // Reset current stages and metadata for (let i = 0; i < stageIds.length; i++) { const stageId = stageIds[i]; const newStage = new Stage(stageId, cacheReaders?.[i]); From 0cea7a8d3fe3df30917185d51045f1a98dedde86 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 19 Dec 2025 11:04:16 +0100 Subject: [PATCH 039/223] test(builder): Sort files/folders --- packages/builder/test/utils/fshelper.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builder/test/utils/fshelper.js b/packages/builder/test/utils/fshelper.js index 25e74974b79..9d069654a92 100644 --- a/packages/builder/test/utils/fshelper.js +++ b/packages/builder/test/utils/fshelper.js @@ -11,8 +11,8 @@ export async function readFileContent(filePath) { } export async function directoryDeepEqual(t, destPath, expectedPath) { - const dest = await readdir(destPath, {recursive: true}); - const expected = await readdir(expectedPath, {recursive: true}); + const dest = (await readdir(destPath, {recursive: true})).sort(); + const expected = (await readdir(expectedPath, {recursive: true})).sort(); t.deepEqual(dest, expected); } From 41acad8dbbe53a9b47dd3121f780a3859f38dc91 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 19 Dec 2025 11:05:03 +0100 Subject: [PATCH 040/223] refactor(builder): Prevent duplicate entries on app build from cache --- packages/project/lib/specifications/ComponentProject.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index e78047d1f5e..3044d0f7d68 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -237,8 +237,10 @@ class ComponentProject extends Project { reader: namespaceWriter, namespace: this._namespace })); - // Add general writer as is - readers.push(generalWriter); + // Add general writer only if it differs to prevent duplicate entries (with and without namespace) + if (namespaceWriter !== generalWriter) { + readers.push(generalWriter); + } break; case "flat": // Rewrite paths from "flat" to "buildtime" From 8431c71e0197512fa20c1474c39f95d9fc2d593d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 23 Dec 2025 21:32:26 +0100 Subject: [PATCH 041/223] refactor(fs): Refactor MonitorReader API --- packages/fs/lib/MonitoredReader.js | 36 ++++------------ packages/fs/lib/MonitoredReaderWriter.js | 52 ++++++++---------------- 2 files changed, 26 insertions(+), 62 deletions(-) diff --git a/packages/fs/lib/MonitoredReader.js b/packages/fs/lib/MonitoredReader.js index db2acaf4c8d..820b75169fe 100644 --- a/packages/fs/lib/MonitoredReader.js +++ b/packages/fs/lib/MonitoredReader.js @@ -3,23 +3,19 @@ import AbstractReader from "./AbstractReader.js"; export default class MonitoredReader extends AbstractReader { #reader; #sealed = false; - #pathsRead = []; + #paths = []; #patterns = []; - #resourcesRead = Object.create(null); constructor(reader) { super(reader.getName()); this.#reader = reader; } - getResults() { + getResourceRequests() { this.#sealed = true; return { - requests: { - pathsRead: this.#pathsRead, - patterns: this.#patterns, - }, - resourcesRead: this.#resourcesRead, + paths: this.#paths, + patterns: this.#patterns, }; } @@ -30,20 +26,10 @@ export default class MonitoredReader extends AbstractReader { if (this.#reader.resolvePattern) { const resolvedPattern = this.#reader.resolvePattern(virPattern); this.#patterns.push(resolvedPattern); - } else if (virPattern instanceof Array) { - for (const pattern of virPattern) { - this.#patterns.push(pattern); - } } else { this.#patterns.push(virPattern); } - const resources = await this.#reader._byGlob(virPattern, options, trace); - for (const resource of resources) { - if (!resource.getStatInfo()?.isDirectory()) { - this.#resourcesRead[resource.getOriginalPath()] = resource; - } - } - return resources; + return await this.#reader._byGlob(virPattern, options, trace); } async _byPath(virPath, options, trace) { @@ -53,17 +39,11 @@ export default class MonitoredReader extends AbstractReader { if (this.#reader.resolvePath) { const resolvedPath = this.#reader.resolvePath(virPath); if (resolvedPath) { - this.#pathsRead.push(resolvedPath); + this.#paths.push(resolvedPath); } } else { - this.#pathsRead.push(virPath); - } - const resource = await this.#reader._byPath(virPath, options, trace); - if (resource) { - if (!resource.getStatInfo()?.isDirectory()) { - this.#resourcesRead[resource.getOriginalPath()] = resource; - } + this.#paths.push(virPath); } - return resource; + return await this.#reader._byPath(virPath, options, trace); } } diff --git a/packages/fs/lib/MonitoredReaderWriter.js b/packages/fs/lib/MonitoredReaderWriter.js index 22b46c12b79..4a42c2980d6 100644 --- a/packages/fs/lib/MonitoredReaderWriter.js +++ b/packages/fs/lib/MonitoredReaderWriter.js @@ -3,49 +3,39 @@ import AbstractReaderWriter from "./AbstractReaderWriter.js"; export default class MonitoredReaderWriter extends AbstractReaderWriter { #readerWriter; #sealed = false; - #pathsRead = []; - #patterns = []; - #resourcesRead = Object.create(null); - #resourcesWritten = Object.create(null); + #paths = new Set(); + #patterns = new Set(); + #pathsWritten = new Set(); constructor(readerWriter) { super(readerWriter.getName()); this.#readerWriter = readerWriter; } - getResults() { + getResourceRequests() { this.#sealed = true; return { - requests: { - pathsRead: this.#pathsRead, - patterns: this.#patterns, - }, - resourcesRead: this.#resourcesRead, - resourcesWritten: this.#resourcesWritten, + paths: this.#paths, + patterns: this.#patterns, }; } + getWrittenResourcePaths() { + this.#sealed = true; + return this.#pathsWritten; + } + async _byGlob(virPattern, options, trace) { if (this.#sealed) { throw new Error(`Unexpected read operation after reader has been sealed`); } if (this.#readerWriter.resolvePattern) { const resolvedPattern = this.#readerWriter.resolvePattern(virPattern); - this.#patterns.push(resolvedPattern); - } else if (virPattern instanceof Array) { - for (const pattern of virPattern) { - this.#patterns.push(pattern); - } + this.#patterns.add(resolvedPattern); } else { - this.#patterns.push(virPattern); + this.#patterns.add(virPattern); } - const resources = await this.#readerWriter._byGlob(virPattern, options, trace); - for (const resource of resources) { - if (!resource.getStatInfo()?.isDirectory()) { - this.#resourcesRead[resource.getOriginalPath()] = resource; - } - } - return resources; + return await this.#readerWriter._byGlob(virPattern, options, trace); } async _byPath(virPath, options, trace) { @@ -55,18 +45,12 @@ export default class MonitoredReaderWriter extends AbstractReaderWriter { if (this.#readerWriter.resolvePath) { const resolvedPath = this.#readerWriter.resolvePath(virPath); if (resolvedPath) { - this.#pathsRead.push(resolvedPath); + this.#paths.add(resolvedPath); } } else { - this.#pathsRead.push(virPath); - } - const resource = await this.#readerWriter._byPath(virPath, options, trace); - if (resource) { - if (!resource.getStatInfo()?.isDirectory()) { - this.#resourcesRead[resource.getOriginalPath()] = resource; - } + this.#paths.add(virPath); } - return resource; + return await this.#readerWriter._byPath(virPath, options, trace); } async _write(resource, options) { @@ -76,7 +60,7 @@ export default class MonitoredReaderWriter extends AbstractReaderWriter { if (!resource) { throw new Error(`Cannot write undefined resource`); } - this.#resourcesWritten[resource.getOriginalPath()] = resource; + this.#pathsWritten.add(resource.getOriginalPath()); return this.#readerWriter.write(resource, options); } } From c36255164c9ed0eb1bd08f249f04362afb7eac6a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 24 Dec 2025 10:12:07 +0100 Subject: [PATCH 042/223] refactor(fs): Always calculate integrity on clone Otherwise we might constantly recalculate the integrity of the clones, since it's never cached on the original resource --- packages/fs/lib/Resource.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index 451580d308f..ac873613478 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -557,7 +557,13 @@ class Resource { this.#integrity = ssri.fromData(this.#content, SSRI_OPTIONS).toString(); break; case CONTENT_TYPES.FACTORY: + // TODO: Investigate performance impact of buffer factory vs. stream factory for integrity calculation + // if (this.#createBufferFactory) { + // this.#integrity = ssri.fromData( + // await this.#getBufferFromFactory(this.#createBufferFactory, SSRI_OPTIONS).toString()); + // } else { this.#integrity = (await ssri.fromStream(this.#createStreamFactory(), SSRI_OPTIONS)).toString(); + // } break; case CONTENT_TYPES.STREAM: // To be discussed: Should we read the stream into a buffer here (using #getBufferFromStream) to avoid @@ -784,9 +790,9 @@ class Resource { path: this.#path, statInfo: this.#statInfo, // Will be cloned in constructor isDirectory: this.#isDirectory, - byteSize: this.#byteSize, + byteSize: this.#isDirectory ? undefined : await this.getSize(), lastModified: this.#lastModified, - integrity: this.#integrity, + integrity: this.#isDirectory ? undefined : await this.getIntegrity(), sourceMetadata: clone(this.#sourceMetadata) }; From 8fb7fce1734d5a15e3ca0423d3ba7050bab0458f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 29 Dec 2025 19:58:54 +0100 Subject: [PATCH 043/223] refactor(fs): Add getter to WriterCollection --- packages/fs/lib/WriterCollection.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/fs/lib/WriterCollection.js b/packages/fs/lib/WriterCollection.js index f601867632e..a9286a424b7 100644 --- a/packages/fs/lib/WriterCollection.js +++ b/packages/fs/lib/WriterCollection.js @@ -66,6 +66,10 @@ class WriterCollection extends AbstractReaderWriter { }); } + getMapping() { + return this._writerMapping; + } + /** * Locates resources by glob. * From 7c12ffee497b0ae167a568f99479ac61223e9302 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 24 Dec 2025 10:18:35 +0100 Subject: [PATCH 044/223] refactor(builder): Remove cache handling from tasks --- packages/builder/lib/tasks/minify.js | 6 +----- packages/builder/lib/tasks/replaceBuildtime.js | 7 +------ packages/builder/lib/tasks/replaceCopyright.js | 6 +----- packages/builder/lib/tasks/replaceVersion.js | 7 +------ 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index f4aa89d2fe0..5186f8d262d 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -30,11 +30,7 @@ export default async function({ workspace, taskUtil, cacheUtil, options: {pattern, omitSourceMapResources = false, useInputSourceMaps = true} }) { - let resources = await workspace.byGlob(pattern); - if (cacheUtil.hasCache()) { - const changedPaths = cacheUtil.getChangedProjectResourcePaths(); - resources = resources.filter((resource) => changedPaths.has(resource.getPath())); - } + const resources = await workspace.byGlob(pattern); if (resources.length === 0) { return; } diff --git a/packages/builder/lib/tasks/replaceBuildtime.js b/packages/builder/lib/tasks/replaceBuildtime.js index 19e3c853569..8cbe83b5713 100644 --- a/packages/builder/lib/tasks/replaceBuildtime.js +++ b/packages/builder/lib/tasks/replaceBuildtime.js @@ -34,12 +34,7 @@ function getTimestamp() { * @returns {Promise} Promise resolving with undefined once data has been written */ export default async function({workspace, cacheUtil, options: {pattern}}) { - let resources = await workspace.byGlob(pattern); - - if (cacheUtil.hasCache()) { - const changedPaths = cacheUtil.getChangedProjectResourcePaths(); - resources = resources.filter((resource) => changedPaths.has(resource.getPath())); - } + const resources = await workspace.byGlob(pattern); const timestamp = getTimestamp(); const processedResources = await stringReplacer({ resources, diff --git a/packages/builder/lib/tasks/replaceCopyright.js b/packages/builder/lib/tasks/replaceCopyright.js index 927cd30c0f2..103e43e3003 100644 --- a/packages/builder/lib/tasks/replaceCopyright.js +++ b/packages/builder/lib/tasks/replaceCopyright.js @@ -38,11 +38,7 @@ export default async function({workspace, cacheUtil, options: {copyright, patter // Replace optional placeholder ${currentYear} with the current year copyright = copyright.replace(/(?:\$\{currentYear\})/, new Date().getFullYear()); - let resources = await workspace.byGlob(pattern); - if (cacheUtil.hasCache()) { - const changedPaths = cacheUtil.getChangedProjectResourcePaths(); - resources = resources.filter((resource) => changedPaths.has(resource.getPath())); - } + const resources = await workspace.byGlob(pattern); const processedResources = await stringReplacer({ resources, diff --git a/packages/builder/lib/tasks/replaceVersion.js b/packages/builder/lib/tasks/replaceVersion.js index 39192b44d03..b1cd2eb1d16 100644 --- a/packages/builder/lib/tasks/replaceVersion.js +++ b/packages/builder/lib/tasks/replaceVersion.js @@ -21,12 +21,7 @@ import stringReplacer from "../processors/stringReplacer.js"; * @returns {Promise} Promise resolving with undefined once data has been written */ export default async function({workspace, cacheUtil, options: {pattern, version}}) { - let resources = await workspace.byGlob(pattern); - - if (cacheUtil.hasCache()) { - const changedPaths = cacheUtil.getChangedProjectResourcePaths(); - resources = resources.filter((resource) => changedPaths.has(resource.getPath())); - } + const resources = await workspace.byGlob(pattern); const processedResources = await stringReplacer({ resources, options: { From b3b4955393a1ab66d53e647f5e7fb532697b399c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 30 Dec 2025 15:26:00 +0100 Subject: [PATCH 045/223] refactor(builder): Add env variable for disabling workers --- packages/builder/lib/tasks/buildThemes.js | 2 +- packages/builder/lib/tasks/minify.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builder/lib/tasks/buildThemes.js b/packages/builder/lib/tasks/buildThemes.js index d407bb97ff7..de9f2e4d6fe 100644 --- a/packages/builder/lib/tasks/buildThemes.js +++ b/packages/builder/lib/tasks/buildThemes.js @@ -192,7 +192,7 @@ export default async function({ } let processedResources; - const useWorkers = !!taskUtil; + const useWorkers = !process.env.UI5_CLI_NO_WORKERS && !!taskUtil; if (useWorkers) { const threadMessageHandler = new FsMainThreadInterface(fsInterface(combo)); diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index 5186f8d262d..069212db989 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -41,7 +41,7 @@ export default async function({ options: { addSourceMappingUrl: !omitSourceMapResources, readSourceMappingUrl: !!useInputSourceMaps, - useWorkers: !!taskUtil, + useWorkers: !process.env.UI5_CLI_NO_WORKERS && !!taskUtil, } }); From ec0b7b9b7062d03948f0065cc8a98d0874eee897 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 23 Dec 2025 21:32:52 +0100 Subject: [PATCH 046/223] refactor(project): Track resource changes using hash trees refactor(project): Project refactoring refactor(project): Cleanup refactor(project): Fix missing invalidation of tasks on stage replace refactor(project): Stages always contain a writer refactor(project): Refactor serialization refactor(project): Cleanup refactor(project): Refactor project stages A stage now either has a single writer or a single reader (from cache) refactor(project): Rename MerkleTree => HashTree refactor(project): Cleanup refactor(project): Improve HashTree resource updates refactor(project): Add resource metadata to HashTree refactor(project): Use Resource instances in HashTree directly refactor(project): Update HashTree usage refactor(project): Refactor refactor(project): Add upsert to HashTree refactor(project): Refactor result stage cache refactor(project): Remove result stage cache refactor(project): Cleanup refactor(project): Update JSDoc refactor(project): Update JSDoc refactor(project): Split HashTree and TreeRegistry tests refactor(project): Add tests refactor(project): Add strict resource comparison refactor(project): Optimize shared tree state refactor(project): Re-add result stage cache test(project): Add cache tests refactor(project): Cleanup --- packages/project/lib/build/ProjectBuilder.js | 6 +- packages/project/lib/build/TaskRunner.js | 10 +- .../project/lib/build/cache/BuildTaskCache.js | 526 +++++--- .../project/lib/build/cache/CacheManager.js | 334 ++++- .../lib/build/cache/ProjectBuildCache.js | 803 +++++++----- .../lib/build/cache/ResourceRequestGraph.js | 628 ++++++++++ .../project/lib/build/cache/StageCache.js | 89 ++ .../project/lib/build/cache/index/HashTree.js | 1103 +++++++++++++++++ .../lib/build/cache/index/ResourceIndex.js | 233 ++++ .../lib/build/cache/index/TreeRegistry.js | 379 ++++++ packages/project/lib/build/cache/utils.js | 174 ++- .../lib/build/helpers/ProjectBuildContext.js | 24 +- .../project/lib/build/helpers/WatchHandler.js | 4 +- .../lib/specifications/ComponentProject.js | 41 +- .../project/lib/specifications/Project.js | 251 ++-- .../test/lib/build/cache/BuildTaskCache.js | 644 ++++++++++ .../test/lib/build/cache/ProjectBuildCache.js | 573 +++++++++ .../lib/build/cache/ResourceRequestGraph.js | 988 +++++++++++++++ .../test/lib/build/cache/index/HashTree.js | 551 ++++++++ .../lib/build/cache/index/TreeRegistry.js | 567 +++++++++ 20 files changed, 7223 insertions(+), 705 deletions(-) create mode 100644 packages/project/lib/build/cache/ResourceRequestGraph.js create mode 100644 packages/project/lib/build/cache/StageCache.js create mode 100644 packages/project/lib/build/cache/index/HashTree.js create mode 100644 packages/project/lib/build/cache/index/ResourceIndex.js create mode 100644 packages/project/lib/build/cache/index/TreeRegistry.js create mode 100644 packages/project/test/lib/build/cache/BuildTaskCache.js create mode 100644 packages/project/test/lib/build/cache/ProjectBuildCache.js create mode 100644 packages/project/test/lib/build/cache/ResourceRequestGraph.js create mode 100644 packages/project/test/lib/build/cache/index/HashTree.js create mode 100644 packages/project/test/lib/build/cache/index/TreeRegistry.js diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index b7ae8ad59d8..8de36819e61 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -262,7 +262,6 @@ class ProjectBuilder { await projectBuildContext.getTaskRunner().runTasks(); this.#log.endProjectBuild(projectName, projectType); } - project.sealWorkspace(); if (!requestedProjects.includes(projectName)) { // Project has not been requested // => Its resources shall not be part of the build result @@ -280,7 +279,7 @@ class ProjectBuilder { project, this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().saveToDisk(buildManifest)); + pWrites.push(projectBuildContext.getBuildCache().storeCache(buildManifest)); } } await Promise.all(pWrites); @@ -335,7 +334,6 @@ class ProjectBuilder { this.#log.startProjectBuild(projectName, projectType); await projectBuildContext.runTasks(); - project.sealWorkspace(); this.#log.endProjectBuild(projectName, projectType); if (!requestedProjects.includes(projectName)) { // Project has not been requested @@ -353,7 +351,7 @@ class ProjectBuilder { project, this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().saveToDisk(buildManifest)); + pWrites.push(projectBuildContext.getBuildCache().storeCache(buildManifest)); } await Promise.all(pWrites); } diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index dd874116768..f5c833ede47 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -96,6 +96,7 @@ class TaskRunner { name: `Dependency reader collection of project ${project.getName()}`, readers: depReaders }); + this._buildCache.setDependencyReader(this._allDependenciesReader); } /** @@ -130,6 +131,7 @@ class TaskRunner { await this._executeTask(taskName, taskFunction); } } + this._buildCache.allTasksCompleted(); } /** @@ -192,9 +194,8 @@ class TaskRunner { task = async (log) => { options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); - // TODO: Apply cache and stage handling for custom tasks as well - const requiresRun = await this._buildCache.prepareTaskExecution(taskName, this._allDependenciesReader); + const requiresRun = await this._buildCache.prepareTaskExecution(taskName, requiresDependencies); if (!requiresRun) { this._log.skipTask(taskName); return; @@ -241,7 +242,10 @@ class TaskRunner { `Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); } this._log.endTask(taskName); - await this._buildCache.recordTaskResult(taskName, expectedOutput, workspace, dependencies); + await this._buildCache.recordTaskResult(taskName, + workspace.getWrittenResourcePaths(), + workspace.getResourceRequests(), + dependencies?.getResourceRequests()); }; } this._tasks[taskName] = { diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 7f1649e953f..86756dc5b72 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -1,86 +1,68 @@ import micromatch from "micromatch"; -import {getLogger} from "@ui5/logger"; -import {createResourceIndex, areResourcesEqual} from "./utils.js"; -const log = getLogger("build:cache:BuildTaskCache"); +// import {getLogger} from "@ui5/logger"; +import ResourceRequestGraph, {Request} from "./ResourceRequestGraph.js"; +import ResourceIndex from "./index/ResourceIndex.js"; +import TreeRegistry from "./index/TreeRegistry.js"; +// const log = getLogger("build:cache:BuildTaskCache"); /** - * @typedef {object} RequestMetadata - * @property {string[]} pathsRead - Specific resource paths that were read - * @property {string[]} patterns - Glob patterns used to read resources + * @typedef {object} @ui5/project/build/cache/BuildTaskCache~ResourceRequests + * @property {Set} paths - Specific resource paths that were accessed + * @property {Set} patterns - Glob patterns used to access resources */ /** * @typedef {object} TaskCacheMetadata - * @property {RequestMetadata} [projectRequests] - Project resource requests - * @property {RequestMetadata} [dependencyRequests] - Dependency resource requests - * @property {Object} [resourcesRead] - Resources read by task - * @property {Object} [resourcesWritten] - Resources written by task + * @property {object} requestSetGraph - Serialized resource request graph + * @property {Array} requestSetGraph.nodes - Graph nodes representing request sets + * @property {number} requestSetGraph.nextId - Next available node ID */ -function unionArray(arr, items) { - for (const item of items) { - if (!arr.includes(item)) { - arr.push(item); - } - } -} -function unionObject(target, obj) { - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - target[key] = obj[key]; - } - } -} - /** * Manages the build cache for a single task * - * Tracks resource reads/writes and provides methods to validate cache validity - * based on resource changes. + * This class tracks all resources accessed by a task (both project and dependency resources) + * and maintains a graph of resource request sets. Each request set represents a unique + * combination of resource accesses, enabling efficient cache invalidation and reuse. + * + * Key features: + * - Tracks resource reads using paths and glob patterns + * - Maintains resource indices for different request combinations + * - Supports incremental updates when resources change + * - Provides cache invalidation based on changed resources + * - Serializes/deserializes cache metadata for persistence + * + * The request graph allows derived request sets (when a task reads additional resources) + * to reuse existing resource indices, optimizing both memory and computation. */ export default class BuildTaskCache { - #projectName; + // #projectName; #taskName; - // Track which resource paths (and patterns) the task reads - // This is used to check whether a resource change *might* invalidates the task - #projectRequests; - #dependencyRequests; - - // Track metadata for the actual resources the task has read and written - // This is used to check whether a resource has actually changed from the last time the task has been executed (and - // its result has been cached) - // Per resource path, this reflects the last known state of the resource (a task might be executed multiple times, - // i.e. with a small delta of changed resources) - // This map can contain either a resource instance (if the cache has been filled during this session) or an object - // containing the last modified timestamp and an md5 hash of the resource (if the cache has been loaded from disk) - #resourcesRead; - #resourcesWritten; + #resourceRequests; + #treeRegistries = []; // ===== LIFECYCLE ===== /** * Creates a new BuildTaskCache instance * - * @param {string} projectName - Name of the project - * @param {string} taskName - Name of the task - * @param {TaskCacheMetadata} metadata - Task cache metadata + * @param {string} projectName - Name of the project (currently unused but reserved for logging) + * @param {string} taskName - Name of the task this cache manages + * @param {string} buildSignature - Build signature for the current build (currently unused but reserved) + * @param {TaskCacheMetadata} [metadata] - Previously cached metadata to restore from. + * If provided, reconstructs the resource request graph from serialized data. + * If omitted, starts with an empty request graph. */ - constructor(projectName, taskName, {projectRequests, dependencyRequests, input, output} = {}) { - this.#projectName = projectName; + constructor(projectName, taskName, buildSignature, metadata) { + // this.#projectName = projectName; this.#taskName = taskName; - this.#projectRequests = projectRequests ?? { - pathsRead: [], - patterns: [], - }; - - this.#dependencyRequests = dependencyRequests ?? { - pathsRead: [], - patterns: [], - }; - this.#resourcesRead = input ?? Object.create(null); - this.#resourcesWritten = output ?? Object.create(null); + if (metadata) { + this.#resourceRequests = ResourceRequestGraph.fromCacheObject(metadata.requestSetGraph); + } else { + this.#resourceRequests = new ResourceRequestGraph(); + } } // ===== METADATA ACCESS ===== @@ -95,138 +77,386 @@ export default class BuildTaskCache { } /** - * Updates the task cache with new resource metadata + * Gets all possible stage signatures for this task + * + * Returns signatures from all recorded request sets. Each signature represents + * a unique combination of resources that were accessed during task execution. + * Used to look up cached build stages. * - * @param {RequestMetadata} projectRequests - Project resource requests - * @param {RequestMetadata} [dependencyRequests] - Dependency resource requests - * @param {Object} resourcesRead - Resources read by task - * @param {Object} resourcesWritten - Resources written by task - * @returns {void} + * @param {module:@ui5/fs.AbstractReader} [projectReader] - Reader for project resources (currently unused) + * @param {module:@ui5/fs.AbstractReader} [dependencyReader] - Reader for dependency resources (currently unused) + * @returns {Promise} Array of stage signature strings + * @throws {Error} If resource index is missing for any request set */ - updateMetadata(projectRequests, dependencyRequests, resourcesRead, resourcesWritten) { - unionArray(this.#projectRequests.pathsRead, projectRequests.pathsRead); - unionArray(this.#projectRequests.patterns, projectRequests.patterns); + async getPossibleStageSignatures(projectReader, dependencyReader) { + const requestSetIds = this.#resourceRequests.getAllNodeIds(); + const signatures = requestSetIds.map((requestSetId) => { + const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Resource index missing for request set ID ${requestSetId}`); + } + return resourceIndex.getSignature(); + }); + return signatures; + } - if (dependencyRequests) { - unionArray(this.#dependencyRequests.pathsRead, dependencyRequests.pathsRead); - unionArray(this.#dependencyRequests.patterns, dependencyRequests.patterns); + /** + * Updates resource indices for request sets affected by changed resources + * + * This method: + * 1. Traverses the request graph to find request sets matching changed resources + * 2. Restores missing resource indices if needed + * 3. Updates or removes resources in affected indices + * 4. Flushes all tree registries to apply batched changes + * + * Changes propagate from parent to child nodes in the request graph, ensuring + * all derived request sets are updated consistently. + * + * @param {Set} changedProjectResourcePaths - Set of changed project resource paths + * @param {Set} changedDepResourcePaths - Set of changed dependency resource paths + * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources + * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for accessing dependency resources + * @returns {Promise} + */ + async updateIndices(changedProjectResourcePaths, changedDepResourcePaths, projectReader, dependencyReader) { + // Filter relevant resource changes and update the indices if necessary + const matchingRequestSetIds = []; + const updatesByRequestSetId = new Map(); + const changedProjectResourcePathsArray = Array.from(changedProjectResourcePaths); + const changedDepResourcePathsArray = Array.from(changedDepResourcePaths); + // Process all nodes, parents before children + for (const {nodeId, node, parentId} of this.#resourceRequests.traverseByDepth()) { + const addedRequests = node.getAddedRequests(); // Resource requests added at this level + let relevantUpdates; + if (addedRequests.length) { + relevantUpdates = this.#matchResourcePaths( + addedRequests, changedProjectResourcePathsArray, changedDepResourcePathsArray); + } else { + relevantUpdates = []; + } + if (parentId) { + // Include updates from parent nodes + const parentUpdates = updatesByRequestSetId.get(parentId); + if (parentUpdates && parentUpdates.length) { + relevantUpdates.push(...parentUpdates); + } + } + if (relevantUpdates.length) { + if (!this.#resourceRequests.getMetadata(nodeId).resourceIndex) { + // Restore missing resource index + await this.#restoreResourceIndex(nodeId, projectReader, dependencyReader); + continue; // Index is fresh now, no need to update again + } + updatesByRequestSetId.set(nodeId, relevantUpdates); + matchingRequestSetIds.push(nodeId); + } } - unionObject(this.#resourcesRead, resourcesRead); - unionObject(this.#resourcesWritten, resourcesWritten); + const resourceCache = new Map(); + // Update matching resource indices + for (const requestSetId of matchingRequestSetIds) { + const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); + + const resourcePathsToUpdate = updatesByRequestSetId.get(requestSetId); + const resourcesToUpdate = []; + const removedResourcePaths = []; + for (const resourcePath of resourcePathsToUpdate) { + let resource; + if (resourceCache.has(resourcePath)) { + resource = resourceCache.get(resourcePath); + } else { + if (changedDepResourcePaths.has(resourcePath)) { + resource = await dependencyReader.byPath(resourcePath); + } else { + resource = await projectReader.byPath(resourcePath); + } + resourceCache.set(resourcePath, resource); + } + if (resource) { + resourcesToUpdate.push(resource); + } else { + // Resource has been removed + removedResourcePaths.push(resourcePath); + } + } + if (removedResourcePaths.length) { + await resourceIndex.removeResources(removedResourcePaths); + } + if (resourcesToUpdate.length) { + await resourceIndex.upsertResources(resourcesToUpdate); + } + } + return await this.#flushTreeRegistries(); } /** - * Serializes the task cache to a JSON-compatible object + * Restores a missing resource index for a request set + * + * Recursively restores parent indices first, then derives or creates the index + * for the current request set. Uses tree derivation when a parent index exists + * to share common resources efficiently. * - * @returns {Promise} Serialized task cache data + * @private + * @param {number} requestSetId - ID of the request set to restore + * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for project resources + * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for dependency resources + * @returns {Promise} The restored resource index */ - async createMetadata() { - return { - projectRequests: this.#projectRequests, - dependencyRequests: this.#dependencyRequests, - taskIndex: await createResourceIndex(Object.values(this.#resourcesRead)), - // resourcesWritten: await createMetadataForResources(this.#resourcesWritten) - }; + async #restoreResourceIndex(requestSetId, projectReader, dependencyReader) { + const node = this.#resourceRequests.getNode(requestSetId); + const addedRequests = node.getAddedRequests(); + const parentId = node.getParentId(); + let resourceIndex; + if (parentId) { + let {resourceIndex: parentResourceIndex} = this.#resourceRequests.getMetadata(parentId); + if (!parentResourceIndex) { + // Restore parent index first + parentResourceIndex = await this.#restoreResourceIndex(parentId, projectReader, dependencyReader); + } + // Add resources from delta to index + const resourcesToAdd = this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); + resourceIndex = parentResourceIndex.deriveTree(resourcesToAdd); + } else { + const resourcesRead = + await this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); + resourceIndex = await ResourceIndex.create(resourcesRead, this.#newTreeRegistry()); + } + const metadata = this.#resourceRequests.getMetadata(requestSetId); + metadata.resourceIndex = resourceIndex; + return resourceIndex; } - // ===== VALIDATION ===== - /** - * Checks if changed resources match this task's tracked resources + * Matches changed resources against a set of requests * - * This is a fast check that determines if the task *might* be invalidated - * based on path matching and glob patterns. + * Tests each request against the changed resource paths using exact path matching + * for 'path'/'dep-path' requests and glob pattern matching for 'patterns'/'dep-patterns' requests. * - * @param {Set|string[]} projectResourcePaths - Changed project resource paths - * @param {Set|string[]} dependencyResourcePaths - Changed dependency resource paths - * @returns {boolean} True if any changed resources match this task's tracked resources + * @private + * @param {Request[]} resourceRequests - Array of resource requests to match against + * @param {string[]} projectResourcePaths - Changed project resource paths + * @param {string[]} dependencyResourcePaths - Changed dependency resource paths + * @returns {string[]} Array of matched resource paths + * @throws {Error} If an unknown request type is encountered */ - matchesChangedResources(projectResourcePaths, dependencyResourcePaths) { - if (this.#isRelevantResourceChange(this.#projectRequests, projectResourcePaths)) { - log.verbose( - `Build cache for task ${this.#taskName} of project ${this.#projectName} possibly invalidated ` + - `by changes made to the following resources ${Array.from(projectResourcePaths).join(", ")}`); - return true; + #matchResourcePaths(resourceRequests, projectResourcePaths, dependencyResourcePaths) { + const matchedResources = []; + for (const {type, value} of resourceRequests) { + switch (type) { + case "path": + if (projectResourcePaths.includes(value)) { + matchedResources.push(value); + } + break; + case "patterns": + matchedResources.push(...micromatch(projectResourcePaths, value)); + break; + case "dep-path": + if (dependencyResourcePaths.includes(value)) { + matchedResources.push(value); + } + break; + case "dep-patterns": + matchedResources.push(...micromatch(dependencyResourcePaths, value)); + break; + default: + throw new Error(`Unknown request type: ${type}`); + } } + return matchedResources; + } - if (this.#isRelevantResourceChange(this.#dependencyRequests, dependencyResourcePaths)) { - log.verbose( - `Build cache for task ${this.#taskName} of project ${this.#projectName} possibly invalidated ` + - `by changes made to the following resources: ${Array.from(dependencyResourcePaths).join(", ")}`); - return true; + /** + * Calculates a signature for the task based on accessed resources + * + * This method: + * 1. Converts resource requests to Request objects + * 2. Searches for an exact match in the request graph + * 3. If found, returns the existing index signature + * 4. If not found, creates a new request set and resource index + * 5. Uses tree derivation when possible to reuse parent indices + * + * The signature uniquely identifies the set of resources accessed and their + * content, enabling cache lookup for previously executed task results. + * + * @param {ResourceRequests} projectRequests - Project resource requests (paths and patterns) + * @param {ResourceRequests} [dependencyRequests] - Dependency resource requests (paths and patterns) + * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources + * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for accessing dependency resources + * @returns {Promise} Signature hash string of the resource index + */ + async calculateSignature(projectRequests, dependencyRequests, projectReader, dependencyReader) { + const requests = []; + for (const pathRead of projectRequests.paths) { + requests.push(new Request("path", pathRead)); + } + for (const patterns of projectRequests.patterns) { + requests.push(new Request("patterns", patterns)); } + if (dependencyRequests) { + for (const pathRead of dependencyRequests.paths) { + requests.push(new Request("dep-path", pathRead)); + } + for (const patterns of dependencyRequests.patterns) { + requests.push(new Request("dep-patterns", patterns)); + } + } + let setId = this.#resourceRequests.findExactMatch(requests); + let resourceIndex; + if (setId) { + resourceIndex = this.#resourceRequests.getMetadata(setId).resourceIndex; + // await resourceIndex.updateResources(resourcesRead); // Index was already updated before the task executed + } else { + // New request set, check whether we can create a delta + const metadata = {}; // Will populate with resourceIndex below + setId = this.#resourceRequests.addRequestSet(requests, metadata); - return false; - } - // ===== CACHE LOOKUPS ===== + const requestSet = this.#resourceRequests.getNode(setId); + const parentId = requestSet.getParentId(); + if (parentId) { + const {resourceIndex: parentResourceIndex} = this.#resourceRequests.getMetadata(parentId); + // Add resources from delta to index + const addedRequests = requestSet.getAddedRequests(); + const resourcesToAdd = + await this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); + resourceIndex = await parentResourceIndex.deriveTree(resourcesToAdd); + // await newIndex.add(resourcesToAdd); + } else { + const resourcesRead = + await this.#getResourcesForRequests(requests, projectReader, dependencyReader); + resourceIndex = await ResourceIndex.create(resourcesRead, this.#newTreeRegistry()); + } + metadata.resourceIndex = resourceIndex; + } + return resourceIndex.getSignature(); + } /** - * Gets the cache entry for a resource that was read + * Creates and registers a new tree registry * - * @param {string} searchResourcePath - Path of the resource to look up - * @returns {ResourceMetadata|object|undefined} Cache entry or undefined if not found + * Tree registries enable batched updates across multiple derived trees, + * improving performance when multiple indices share common subtrees. + * + * @private + * @returns {TreeRegistry} New tree registry instance */ - getReadCacheEntry(searchResourcePath) { - return this.#resourcesRead[searchResourcePath]; + #newTreeRegistry() { + const registry = new TreeRegistry(); + this.#treeRegistries.push(registry); + return registry; } /** - * Gets the cache entry for a resource that was written + * Flushes all tree registries to apply batched updates + * + * Commits all pending tree modifications across all registries in parallel. + * Must be called after operations that schedule updates via registries. * - * @param {string} searchResourcePath - Path of the resource to look up - * @returns {ResourceMetadata|object|undefined} Cache entry or undefined if not found + * @private + * @returns {Promise} */ - getWriteCacheEntry(searchResourcePath) { - return this.#resourcesWritten[searchResourcePath]; + async #flushTreeRegistries() { + await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); } /** - * Checks if a resource exists in the read cache and has the same content + * Retrieves resources for a set of resource requests + * + * Processes different request types: + * - 'path': Retrieves single resource by path from project reader + * - 'patterns': Retrieves resources matching glob patterns from project reader + * - 'dep-path': Retrieves single resource by path from dependency reader + * - 'dep-patterns': Retrieves resources matching glob patterns from dependency reader * - * @param {object} resource - Resource instance to check - * @returns {Promise} True if resource is in cache with matching content + * @private + * @param {Request[]|Array<{type: string, value: string|string[]}>} resourceRequests - Resource requests to process + * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for project resources + * @param {module:@ui5/fs.AbstractReader} dependencyReder - Reader for dependency resources + * @returns {Promise>} Iterator of retrieved resources + * @throws {Error} If an unknown request type is encountered */ - async matchResourceInReadCache(resource) { - const cachedResource = this.#resourcesRead[resource.getPath()]; - if (!cachedResource) { - return false; + async #getResourcesForRequests(resourceRequests, projectReader, dependencyReder) { + const resourcesMap = new Map(); + for (const {type, value} of resourceRequests) { + switch (type) { + case "path": { + const resource = await projectReader.byPath(value); + if (resource) { + resourcesMap.set(value, resource); + } + break; + } + case "patterns": { + const matchedResources = await projectReader.byGlob(value); + for (const resource of matchedResources) { + resourcesMap.set(resource.getOriginalPath(), resource); + } + break; + } + case "dep-path": { + const resource = await dependencyReder.byPath(value); + if (resource) { + resourcesMap.set(value, resource); + } + break; + } + case "dep-patterns": { + const matchedResources = await dependencyReder.byGlob(value); + for (const resource of matchedResources) { + resourcesMap.set(resource.getOriginalPath(), resource); + } + break; + } + default: + throw new Error(`Unknown request type: ${type}`); + } } - // if (cachedResource.integrity) { - // return await matchIntegrity(resource, cachedResource); - // } else { - return await areResourcesEqual(resource, cachedResource); - // } + return resourcesMap.values(); } + // ===== VALIDATION ===== + /** - * Checks if a resource exists in the write cache and has the same content + * Checks if changed resources match this task's tracked resources + * + * This is a fast check that determines if the task *might* be invalidated + * based on path matching and glob patterns. * - * @param {object} resource - Resource instance to check - * @returns {Promise} True if resource is in cache with matching content + * @param {string[]} projectResourcePaths - Changed project resource paths + * @param {string[]} dependencyResourcePaths - Changed dependency resource paths + * @returns {boolean} True if any changed resources match this task's tracked resources */ - async matchResourceInWriteCache(resource) { - const cachedResource = this.#resourcesWritten[resource.getPath()]; - if (!cachedResource) { - return false; - } - // if (cachedResource.integrity) { - // return await matchIntegrity(resource, cachedResource); - // } else { - return await areResourcesEqual(resource, cachedResource); - // } - } - - #isRelevantResourceChange({pathsRead, patterns}, changedResourcePaths) { - for (const resourcePath of changedResourcePaths) { - if (pathsRead.includes(resourcePath)) { - return true; + matchesChangedResources(projectResourcePaths, dependencyResourcePaths) { + const resourceRequests = this.#resourceRequests.getAllRequests(); + return resourceRequests.some(({type, value}) => { + if (type === "path") { + return projectResourcePaths.includes(value); } - if (patterns.length && micromatch(resourcePath, patterns).length > 0) { - return true; + if (type === "patterns") { + return micromatch(projectResourcePaths, value).length > 0; } - } - return false; + if (type === "dep-path") { + return dependencyResourcePaths.includes(value); + } + if (type === "dep-patterns") { + return micromatch(dependencyResourcePaths, value).length > 0; + } + throw new Error(`Unknown request type: ${type}`); + }); + } + + /** + * Serializes the task cache to a plain object for persistence + * + * Exports the resource request graph in a format suitable for JSON serialization. + * The serialized data can be passed to the constructor to restore the cache state. + * + * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph + */ + toCacheObject() { + return { + requestSetGraph: this.#resourceRequests.toCacheObject() + }; } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index f39e7ac0541..61630f2e9a5 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -12,26 +12,71 @@ import {getLogger} from "@ui5/logger"; const log = getLogger("build:cache:CacheManager"); +// Singleton instances mapped by cache directory path const chacheManagerInstances = new Map(); + +// Options for cacache operations (using SHA-256 for integrity checks) const CACACHE_OPTIONS = {algorithms: ["sha256"]}; +// Cache version for compatibility management +const CACHE_VERSION = "v0"; + /** - * Persistence management for the build cache. Using a file-based index and cacache + * Manages persistence for the build cache using file-based storage and cacache + * + * CacheManager provides a hierarchical file-based cache structure: + * - cas/ - Content-addressable storage (cacache) for resource content + * - buildManifests/ - Build manifest files containing metadata about builds + * - stageMetadata/ - Stage-level metadata organized by project, build, and stage + * - index/ - Resource index files for efficient change detection * - * cacheDir structure: - * - cas/ -- cacache content addressable storage - * - buildManifests/ -- build manifest files (acting as index, internally referencing cacache entries) + * The cache is organized by: + * 1. Project ID (sanitized package name) + * 2. Build signature (hash of build configuration) + * 3. Stage ID (e.g., "result" or "task/taskName") + * 4. Stage signature (hash of input resources) * + * Key features: + * - Content-addressable storage with integrity verification + * - Singleton pattern per cache directory + * - Configurable cache location via UI5_DATA_DIR or configuration + * - Efficient resource deduplication through cacache */ export default class CacheManager { #casDir; #manifestDir; + #stageMetadataDir; + #indexDir; + /** + * Creates a new CacheManager instance + * + * Initializes the directory structure for the cache. This constructor is private - + * use CacheManager.create() instead to get a singleton instance. + * + * @private + * @param {string} cacheDir - Base directory for the cache + */ constructor(cacheDir) { + cacheDir = path.join(cacheDir, CACHE_VERSION); this.#casDir = path.join(cacheDir, "cas"); this.#manifestDir = path.join(cacheDir, "buildManifests"); + this.#stageMetadataDir = path.join(cacheDir, "stageMetadata"); + this.#indexDir = path.join(cacheDir, "index"); } + /** + * Factory method to create or retrieve a CacheManager instance + * + * Returns a singleton CacheManager for the determined cache directory. + * The cache directory is resolved in this order: + * 1. UI5_DATA_DIR environment variable (resolved relative to cwd) + * 2. ui5DataDir from UI5 configuration file + * 3. Default: ~/.ui5/ + * + * @param {string} cwd - Current working directory for resolving relative paths + * @returns {Promise} Singleton CacheManager instance for the cache directory + */ static async create(cwd) { // ENV var should take precedence over the dataDir from the configuration. let ui5DataDir = process.env.UI5_DATA_DIR; @@ -53,14 +98,30 @@ export default class CacheManager { return chacheManagerInstances.get(cacheDir); } + /** + * Generates the file path for a build manifest + * + * @private + * @param {string} packageName - Package/project identifier + * @param {string} buildSignature - Build signature hash + * @returns {string} Absolute path to the build manifest file + */ #getBuildManifestPath(packageName, buildSignature) { const pkgDir = getPathFromPackageName(packageName); return path.join(this.#manifestDir, pkgDir, `${buildSignature}.json`); } - async readBuildManifest(project, buildSignature) { + /** + * Reads a build manifest from cache + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @returns {Promise} Parsed manifest object or null if not found + * @throws {Error} If file read fails for reasons other than file not existing + */ + async readBuildManifest(projectId, buildSignature) { try { - const manifest = await readFile(this.#getBuildManifestPath(project.getId(), buildSignature), "utf8"); + const manifest = await readFile(this.#getBuildManifestPath(projectId, buildSignature), "utf8"); return JSON.parse(manifest); } catch (err) { if (err.code === "ENOENT") { @@ -71,18 +132,164 @@ export default class CacheManager { } } - async writeBuildManifest(project, buildSignature, manifest) { - const manifestPath = this.#getBuildManifestPath(project.getId(), buildSignature); + /** + * Writes a build manifest to cache + * + * Creates parent directories if they don't exist. Manifests are stored as + * formatted JSON (2-space indentation) for readability. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {object} manifest - Build manifest object to serialize + * @returns {Promise} + */ + async writeBuildManifest(projectId, buildSignature, manifest) { + const manifestPath = this.#getBuildManifestPath(projectId, buildSignature); await mkdir(path.dirname(manifestPath), {recursive: true}); await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); } - async getResourcePathForStage(buildSignature, stageId, resourcePath, integrity) { + /** + * Generates the file path for resource index metadata + * + * @private + * @param {string} packageName - Package/project identifier + * @param {string} buildSignature - Build signature hash + * @returns {string} Absolute path to the index metadata file + */ + #getIndexMetadataPath(packageName, buildSignature) { + const pkgDir = getPathFromPackageName(packageName); + return path.join(this.#indexDir, pkgDir, `${buildSignature}.json`); + } + + /** + * Reads resource index cache from storage + * + * The index cache contains the resource tree structure and task metadata, + * enabling efficient change detection and cache validation. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @returns {Promise} Parsed index cache object or null if not found + * @throws {Error} If file read fails for reasons other than file not existing + */ + async readIndexCache(projectId, buildSignature) { + try { + const metadata = await readFile(this.#getIndexMetadataPath(projectId, buildSignature), "utf8"); + return JSON.parse(metadata); + } catch (err) { + if (err.code === "ENOENT") { + // Cache miss + return null; + } + throw err; + } + } + + /** + * Writes resource index cache to storage + * + * Persists the resource index and associated task metadata for later retrieval. + * Creates parent directories if needed. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {object} index - Index object containing resource tree and task metadata + * @returns {Promise} + */ + async writeIndexCache(projectId, buildSignature, index) { + const indexPath = this.#getIndexMetadataPath(projectId, buildSignature); + await mkdir(path.dirname(indexPath), {recursive: true}); + await writeFile(indexPath, JSON.stringify(index, null, 2), "utf8"); + } + + /** + * Generates the file path for stage metadata + * + * @private + * @param {string} packageName - Package/project identifier + * @param {string} buildSignature - Build signature hash + * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature - Stage signature hash (based on input resources) + * @returns {string} Absolute path to the stage metadata file + */ + #getStageMetadataPath(packageName, buildSignature, stageId, stageSignature) { + const pkgDir = getPathFromPackageName(packageName); + return path.join(this.#stageMetadataDir, pkgDir, buildSignature, stageId, `${stageSignature}.json`); + } + + /** + * Reads stage metadata from cache + * + * Stage metadata contains information about resources produced by a build stage, + * including resource paths and their metadata. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature - Stage signature hash (based on input resources) + * @returns {Promise} Parsed stage metadata or null if not found + * @throws {Error} If file read fails for reasons other than file not existing + */ + async readStageCache(projectId, buildSignature, stageId, stageSignature) { + try { + const metadata = await readFile( + this.#getStageMetadataPath(projectId, buildSignature, stageId, stageSignature + ), "utf8"); + return JSON.parse(metadata); + } catch (err) { + if (err.code === "ENOENT") { + // Cache miss + return null; + } + throw err; + } + } + + /** + * Writes stage metadata to cache + * + * Persists metadata about resources produced by a build stage. + * Creates parent directories if needed. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature - Stage signature hash (based on input resources) + * @param {object} metadata - Stage metadata object to serialize + * @returns {Promise} + */ + async writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata) { + const metadataPath = this.#getStageMetadataPath( + projectId, buildSignature, stageId, stageSignature); + await mkdir(path.dirname(metadataPath), {recursive: true}); + await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + } + + /** + * Retrieves the file system path for a cached resource + * + * Looks up a resource in the content-addressable storage using its cache key + * and verifies its integrity. If integrity mismatches, attempts to recover by + * looking up the content by digest and updating the index. + * + * @param {string} buildSignature - Build signature hash + * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature - Stage signature hash + * @param {string} resourcePath - Virtual path of the resource + * @param {string} integrity - Expected integrity hash (e.g., "sha256-...") + * @returns {Promise} Absolute path to the cached resource file, or null if not found + * @throws {Error} If integrity is not provided + */ + async getResourcePathForStage(buildSignature, stageId, stageSignature, resourcePath, integrity) { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } - const cacheKey = this.#createKeyForStage(buildSignature, stageId, resourcePath); + const cacheKey = this.#createKeyForStage(buildSignature, stageId, stageSignature, resourcePath); const result = await cacache.get.info(this.#casDir, cacheKey); + if (!result) { + return null; + } if (result.integrity !== integrity) { log.info(`Integrity mismatch for cache entry ` + `${cacheKey}: expected ${integrity}, got ${result.integrity}`); @@ -91,43 +298,90 @@ export default class CacheManager { if (res) { log.info(`Updating cache entry with expectation...`); await this.writeStage(buildSignature, stageId, resourcePath, res); - return await this.getResourcePathForStage(buildSignature, stageId, resourcePath, integrity); + return await this.getResourcePathForStage( + buildSignature, stageId, stageSignature, resourcePath, integrity); } } - if (!result) { - return null; - } return result.path; } - async writeStage(buildSignature, stageId, resourcePath, buffer) { - return await cacache.put( - this.#casDir, - this.#createKeyForStage(buildSignature, stageId, resourcePath), - buffer, - CACACHE_OPTIONS - ); + /** + * Writes a resource to the cache for a specific stage + * + * If the resource content (identified by integrity hash) already exists in the + * content-addressable storage, only updates the index with a new cache key. + * Otherwise, writes the full content to storage. + * + * This enables efficient deduplication when the same resource content appears + * in multiple stages or builds. + * + * @param {string} buildSignature - Build signature hash + * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature - Stage signature hash + * @param {module:@ui5/fs.Resource} resource - Resource to cache + * @returns {Promise} + */ + async writeStageResource(buildSignature, stageId, stageSignature, resource) { + // Check if resource has already been written + const integrity = await resource.getIntegrity(); + const hasResource = await cacache.get.hasContent(this.#casDir, integrity); + const cacheKey = this.#createKeyForStage(buildSignature, stageId, stageSignature, resource.getOriginalPath()); + if (!hasResource) { + const buffer = await resource.getBuffer(); + await cacache.put( + this.#casDir, + cacheKey, + buffer, + CACACHE_OPTIONS + ); + } else { + // Update index + await cacache.index.insert(this.#casDir, cacheKey, integrity, CACACHE_OPTIONS); + } } - async writeStageStream(buildSignature, stageId, resourcePath, stream) { - const writable = cacache.put.stream( - this.#casDir, - this.#createKeyForStage(buildSignature, stageId, resourcePath), - stream, - CACACHE_OPTIONS, - ); - return new Promise((resolve, reject) => { - writable.on("integrity", (digest) => { - resolve(digest); - }); - writable.on("error", (err) => { - reject(err); - }); - stream.pipe(writable); - }); - } + // async writeStage(buildSignature, stageId, resourcePath, buffer) { + // return await cacache.put( + // this.#casDir, + // this.#createKeyForStage(buildSignature, stageId, resourcePath), + // buffer, + // CACACHE_OPTIONS + // ); + // } + + // async writeStageStream(buildSignature, stageId, resourcePath, stream) { + // const writable = cacache.put.stream( + // this.#casDir, + // this.#createKeyForStage(buildSignature, stageId, resourcePath), + // stream, + // CACACHE_OPTIONS, + // ); + // return new Promise((resolve, reject) => { + // writable.on("integrity", (digest) => { + // resolve(digest); + // }); + // writable.on("error", (err) => { + // reject(err); + // }); + // stream.pipe(writable); + // }); + // } - #createKeyForStage(buildSignature, stageId, resourcePath) { - return `${buildSignature}|${stageId}|${resourcePath}`; + /** + * Creates a cache key for a resource in a specific stage + * + * The key format is: buildSignature|stageId|stageSignature|resourcePath + * This ensures unique identification of resources across different builds, + * stages, and input combinations. + * + * @private + * @param {string} buildSignature - Build signature hash + * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature - Stage signature hash + * @param {string} resourcePath - Virtual path of the resource + * @returns {string} Cache key string + */ + #createKeyForStage(buildSignature, stageId, stageSignature, resourcePath) { + return `${buildSignature}|${stageId}|${stageSignature}|${resourcePath}`; } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 29fce6b459b..5fdc8006c2a 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -4,26 +4,44 @@ import fs from "graceful-fs"; import {promisify} from "node:util"; const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; -import {createResourceIndex} from "./utils.js"; +import StageCache from "./StageCache.js"; +import ResourceIndex from "./index/ResourceIndex.js"; +import {firstTruthy} from "./utils.js"; const log = getLogger("build:cache:ProjectBuildCache"); +/** + * @typedef {object} StageMetadata + * @property {Object} resourceMetadata + */ + +/** + * @typedef {object} StageCacheEntry + * @property {@ui5/fs/AbstractReader} stage - Reader for the cached stage + * @property {Set} writtenResourcePaths - Set of resource paths written by the task + */ + export default class ProjectBuildCache { #taskCache = new Map(); + #stageCache = new StageCache(); + #project; #buildSignature; + #buildManifest; #cacheManager; + #currentProjectReader; + #dependencyReader; + #resourceIndex; + #requiresInitialBuild; #invalidatedTasks = new Map(); - #updatedResources = new Set(); /** * Creates a new ProjectBuildCache instance * - * @param {object} project Project instance - * @param {string} buildSignature Build signature for the current build - * @param {CacheManager} cacheManager Cache manager instance - * * @private - Use ProjectBuildCache.create() instead + * @param {object} project - Project instance + * @param {string} buildSignature - Build signature for the current build + * @param {object} cacheManager - Cache manager instance for reading/writing cache data */ constructor(project, buildSignature, cacheManager) { this.#project = project; @@ -31,106 +49,278 @@ export default class ProjectBuildCache { this.#cacheManager = cacheManager; } + /** + * Factory method to create and initialize a ProjectBuildCache instance + * + * This is the recommended way to create a ProjectBuildCache as it ensures + * proper asynchronous initialization of the resource index and cache loading. + * + * @param {object} project - Project instance + * @param {string} buildSignature - Build signature for the current build + * @param {object} cacheManager - Cache manager instance + * @returns {Promise} Initialized cache instance + */ static async create(project, buildSignature, cacheManager) { const cache = new ProjectBuildCache(project, buildSignature, cacheManager); - await cache.#attemptLoadFromDisk(); + await cache.#init(); return cache; } + /** + * Initializes the cache by loading resource index, build manifest, and checking cache validity + * + * @private + * @returns {Promise} + */ + async #init() { + this.#resourceIndex = await this.#initResourceIndex(); + this.#buildManifest = await this.#loadBuildManifest(); + this.#requiresInitialBuild = !(await this.#loadIndexCache()); + } + + /** + * Initializes the resource index from cache or creates a new one + * + * This method attempts to load a cached resource index. If found, it validates + * the index against current source files and invalidates affected tasks if + * resources have changed. If no cache exists, creates a fresh index. + * + * @private + * @returns {Promise} The initialized resource index + * @throws {Error} If cached index signature doesn't match computed signature + */ + async #initResourceIndex() { + const sourceReader = this.#project.getSourceReader(); + const [resources, indexCache] = await Promise.all([ + await sourceReader.byGlob("/**/*"), + await this.#cacheManager.readIndexCache(this.#project.getId(), this.#buildSignature), + ]); + if (indexCache) { + log.verbose(`Using cached resource index for project ${this.#project.getName()}`); + // Create and diff resource index + const {resourceIndex, changedPaths} = + await ResourceIndex.fromCacheWithDelta(indexCache, resources); + // Import task caches + + for (const [taskName, metadata] of Object.entries(indexCache.taskMetadata)) { + this.#taskCache.set(taskName, + new BuildTaskCache(this.#project.getName(), taskName, this.#buildSignature, metadata)); + } + if (changedPaths.length) { + // Invalidate tasks based on changed resources + // Note: If the changed paths don't affect any task, the index cache still can't be used due to the + // root hash mismatch. + // Since no tasks have been invalidated, a rebuild is still necessary in this case, so that + // each task can find and use its individual stage cache. + // Hence requiresInitialBuild will be set to true in this case (and others. + this.resourceChanged(changedPaths, []); + } else if (indexCache.indexTree.root.hash !== resourceIndex.getSignature()) { + // Validate index signature matches with cached signature + throw new Error( + `Resource index signature mismatch for project ${this.#project.getName()}: ` + + `expected ${indexCache.indexTree.root.hash}, got ${resourceIndex.getSignature()}`); + } + return resourceIndex; + } + // No index cache found, create new index + return await ResourceIndex.create(resources); + } + // ===== TASK MANAGEMENT ===== /** - * Records the result of a task execution and updates the cache + * Prepares a task for execution by switching to its stage and checking for cached results * * This method: - * 1. Stores metadata about resources read/written by the task - * 2. Detects which resources have actually changed - * 3. Invalidates downstream tasks if necessary - * - * @param {string} taskName Name of the executed task - * @param {Set|undefined} expectedOutput Expected output resource paths - * @param {object} workspaceMonitor Tracker that monitored workspace reads - * @param {object} [dependencyMonitor] Tracker that monitored dependency reads - * @returns {Promise} + * 1. Switches the project to the task's stage + * 2. Updates task indices if the task has been invalidated + * 3. Attempts to find a cached stage for the task + * 4. Returns whether the task needs to be executed + * + * @param {string} taskName - Name of the task to prepare + * @param {boolean} requiresDependencies - Whether the task requires dependency reader + * @returns {Promise} True if task needs execution, false if cached result can be used */ - async recordTaskResult(taskName, expectedOutput, workspaceMonitor, dependencyMonitor) { - const projectTrackingResults = workspaceMonitor.getResults(); - const dependencyTrackingResults = dependencyMonitor?.getResults(); - - const resourcesRead = projectTrackingResults.resourcesRead; - if (dependencyTrackingResults) { - for (const [resourcePath, resource] of Object.entries(dependencyTrackingResults.resourcesRead)) { - resourcesRead[resourcePath] = resource; + async prepareTaskExecution(taskName, requiresDependencies) { + const stageName = this.#getStageNameForTask(taskName); + const taskCache = this.#taskCache.get(taskName); + // Switch project to new stage + this.#project.useStage(stageName); + + if (taskCache) { + if (this.#invalidatedTasks.has(taskName)) { + const {changedProjectResourcePaths, changedDependencyResourcePaths} = + this.#invalidatedTasks.get(taskName); + await taskCache.updateIndices( + changedProjectResourcePaths, changedDependencyResourcePaths, + this.#project.getReader(), this.#dependencyReader); + } // else: Index will be created upon task completion + + // After index update, try to find cached stages for the new signatures + const stageCache = await this.#findStageCache(taskCache, stageName); + if (stageCache) { + // TODO: This might cause more changed resources for following tasks + this.#project.setStage(stageName, stageCache.stage); + + // Task can be skipped, use cached stage as project reader + if (this.#invalidatedTasks.has(taskName)) { + this.#invalidatedTasks.delete(taskName); + } + + if (stageCache.writtenResourcePaths.size) { + // Invalidate following tasks + this.#invalidateFollowingTasks(taskName, stageCache.writtenResourcePaths); + } + return false; // No need to execute the task + } + } + // No cached stage found, store current project reader for later use in recordTaskResult + this.#currentProjectReader = this.#project.getReader(); + return true; // Task needs to be executed + } + + /** + * Attempts to find a cached stage for the given task + * + * Checks both in-memory stage cache and persistent cache storage for a matching + * stage signature. Returns the first matching cached stage found. + * + * @private + * @param {BuildTaskCache} taskCache - Task cache containing possible stage signatures + * @param {string} stageName - Name of the stage to find + * @returns {Promise} Cached stage entry or null if not found + */ + async #findStageCache(taskCache, stageName) { + // Check cache exists and ensure it's still valid before using it + const stageSignatures = await taskCache.getPossibleStageSignatures(); + log.verbose(`Looking for cached stage for task ${stageName} in project ${this.#project.getName()} ` + + `with ${stageSignatures.length} possible signatures:\n - ${stageSignatures.join("\n - ")}`); + if (stageSignatures.length) { + for (const stageSignature of stageSignatures) { + const stageCache = this.#stageCache.getCacheForSignature(stageName, stageSignature); + if (stageCache) { + return stageCache; + } } + + const stageCache = await firstTruthy(stageSignatures.map(async (stageSignature) => { + const stageMetadata = await this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, stageName, stageSignature); + if (stageMetadata) { + const reader = await this.#createReaderForStageCache( + stageName, stageSignature, stageMetadata.resourceMetadata); + return { + stage: reader, + writtenResourcePaths: new Set(Object.keys(stageMetadata.resourceMetadata)), + }; + } + })); + return stageCache; } - const resourcesWritten = projectTrackingResults.resourcesWritten; + } + /** + * Records the result of a task execution and updates the cache + * + * This method: + * 1. Creates a signature for the executed task based on its resource requests + * 2. Stores the resulting stage in the stage cache using that signature + * 3. Invalidates downstream tasks if they depend on written resources + * 4. Removes the task from the invalidated tasks list + * + * @param {string} taskName - Name of the executed task + * @param {Set} writtenResourcePaths - Set of resource paths written by the task + * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} projectResourceRequests + * Resource requests for project resources + * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} dependencyResourceRequests + * Resource requests for dependency resources + * @returns {Promise} + */ + async recordTaskResult(taskName, writtenResourcePaths, projectResourceRequests, dependencyResourceRequests) { if (!this.#taskCache.has(taskName)) { // Initialize task cache - this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName)); - // throw new Error(`Cannot record results for unknown task ${taskName} ` + - // `in project ${this.#project.getName()}`); + this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, this.#buildSignature)); } log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); const taskCache = this.#taskCache.get(taskName); - const writtenResourcePaths = Object.keys(resourcesWritten); - if (writtenResourcePaths.length) { - log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); - - const changedPaths = new Set((await Promise.all(writtenResourcePaths - .map(async (resourcePath) => { - // Check whether resource content actually changed - if (await taskCache.matchResourceInWriteCache(resourcesWritten[resourcePath])) { - return undefined; - } - return resourcePath; - }))).filter((resourcePath) => resourcePath !== undefined)); - - if (!changedPaths.size) { - log.verbose( - `Resources produced by task ${taskName} match with cache from previous executions. ` + - `This task will not invalidate any other tasks`); - return; - } - log.verbose( - `Task ${taskName} produced ${changedPaths.size} resources that might invalidate other tasks`); - for (const resourcePath of changedPaths) { - this.#updatedResources.add(resourcePath); - } - // Check whether other tasks need to be invalidated - const allTasks = Array.from(this.#taskCache.keys()); - const taskIdx = allTasks.indexOf(taskName); - const emptySet = new Set(); - for (let i = taskIdx + 1; i < allTasks.length; i++) { - const nextTaskName = allTasks[i]; - if (!this.#taskCache.get(nextTaskName).matchesChangedResources(changedPaths, emptySet)) { - continue; - } - if (this.#invalidatedTasks.has(taskName)) { - const {changedDependencyResourcePaths} = - this.#invalidatedTasks.get(taskName); - for (const resourcePath of changedPaths) { - changedDependencyResourcePaths.add(resourcePath); - } - } else { - this.#invalidatedTasks.set(taskName, { - changedProjectResourcePaths: changedPaths, - changedDependencyResourcePaths: emptySet - }); - } - } - } - taskCache.updateMetadata( - projectTrackingResults.requests, - dependencyTrackingResults?.requests, - resourcesRead, - resourcesWritten + // Calculate signature for executed task + const stageSignature = await taskCache.calculateSignature( + projectResourceRequests, + dependencyResourceRequests, + this.#currentProjectReader, + this.#dependencyReader ); + // TODO: Read written resources from writer instead of relying on monitor? + // const stage = this.#project.getStage(); + // const stageWriter = stage.getWriter(); + // const writer = stageWriter.collection ? stageWriter.collection : stageWriter; + // const writtenResources = await writer.byGlob("/**/*"); + // if (writtenResources.length !== writtenResourcePaths.size) { + // throw new Error( + // `Mismatch between recorded written resources (${writtenResourcePaths.size}) ` + + // `and actual resources in stage (${writtenResources.length}) for task ${taskName} ` + + // `in project ${this.#project.getName()}`); + // } + + log.verbose(`Storing stage for task ${taskName} in project ${this.#project.getName()} ` + + `with signature ${stageSignature}`); + // Store resulting stage in stage cache + // TODO: Check whether signature already exists and avoid invalidating following tasks + this.#stageCache.addSignature( + this.#getStageNameForTask(taskName), stageSignature, this.#project.getStage(), + writtenResourcePaths); + + // Task has been successfully executed, remove from invalidated tasks if (this.#invalidatedTasks.has(taskName)) { this.#invalidatedTasks.delete(taskName); } + + // Update task cache with new metadata + if (writtenResourcePaths.size) { + log.verbose(`Task ${taskName} produced ${writtenResourcePaths.size} resources`); + this.#invalidateFollowingTasks(taskName, writtenResourcePaths); + } + // Reset current project reader + this.#currentProjectReader = null; + } + + /** + * Invalidates tasks that follow the given task if they depend on written resources + * + * Checks all tasks that come after the given task in execution order and + * invalidates those that match the written resource paths. + * + * @private + * @param {string} taskName - Name of the task that wrote resources + * @param {Set} writtenResourcePaths - Paths of resources written by the task + * @returns {void} + */ + #invalidateFollowingTasks(taskName, writtenResourcePaths) { + const writtenPathsArray = Array.from(writtenResourcePaths); + + // Check whether following tasks need to be invalidated + const allTasks = Array.from(this.#taskCache.keys()); + const taskIdx = allTasks.indexOf(taskName); + for (let i = taskIdx + 1; i < allTasks.length; i++) { + const nextTaskName = allTasks[i]; + if (!this.#taskCache.get(nextTaskName).matchesChangedResources(writtenPathsArray, [])) { + continue; + } + if (this.#invalidatedTasks.has(nextTaskName)) { + const {changedProjectResourcePaths} = + this.#invalidatedTasks.get(nextTaskName); + for (const resourcePath of writtenResourcePaths) { + changedProjectResourcePaths.add(resourcePath); + } + } else { + this.#invalidatedTasks.set(nextTaskName, { + changedProjectResourcePaths: new Set(writtenResourcePaths), + changedDependencyResourcePaths: new Set() + }); + } + } } /** @@ -143,22 +333,18 @@ export default class ProjectBuildCache { return this.#taskCache.get(taskName); } - // ===== INVALIDATION ===== /** - * Collects all modified resource paths and clears the internal tracking set + * Handles resource changes and invalidates affected tasks * - * Note: This method has side effects - it clears the internal modified resources set. - * Call this only when you're ready to consume and process all accumulated changes. + * Iterates through all cached tasks and checks if any match the changed resources. + * Matching tasks are marked as invalidated and will need to be re-executed. + * Changed resource paths are accumulated if a task is already invalidated. * - * @returns {Set} Set of resource paths that have been modified + * @param {string[]} projectResourcePaths - Changed project resource paths + * @param {string[]} dependencyResourcePaths - Changed dependency resource paths + * @returns {boolean} True if any task was invalidated, false otherwise */ - collectAndClearModifiedPaths() { - const updatedResources = new Set(this.#updatedResources); - this.#updatedResources.clear(); - return updatedResources; - } - resourceChanged(projectResourcePaths, dependencyResourcePaths) { let taskInvalidated = false; for (const [taskName, taskCache] of this.#taskCache) { @@ -185,53 +371,6 @@ export default class ProjectBuildCache { return taskInvalidated; } - /** - * Validates whether supposedly changed resources have actually changed - * - * Performs fine-grained validation by comparing resource content (hash/mtime) - * and removes false positives from the invalidation set. - * - * @param {string} taskName - Name of the task to validate - * @param {object} workspace - Workspace reader - * @param {object} dependencies - Dependencies reader - * @returns {Promise} - * @throws {Error} If task cache not found for the given taskName - */ - async validateChangedResources(taskName, workspace, dependencies) { - // Check whether the supposedly changed resources for the task have actually changed - if (!this.#invalidatedTasks.has(taskName)) { - return; - } - const {changedProjectResourcePaths, changedDependencyResourcePaths} = this.#invalidatedTasks.get(taskName); - await this._validateChangedResources(taskName, workspace, changedProjectResourcePaths); - await this._validateChangedResources(taskName, dependencies, changedDependencyResourcePaths); - - if (!changedProjectResourcePaths.size && !changedDependencyResourcePaths.size) { - // Task is no longer invalidated - this.#invalidatedTasks.delete(taskName); - } - } - - async _validateChangedResources(taskName, reader, changedResourcePaths) { - for (const resourcePath of changedResourcePaths) { - const resource = await reader.byPath(resourcePath); - if (!resource) { - // Resource was deleted, no need to check further - continue; - } - - const taskCache = this.#taskCache.get(taskName); - if (!taskCache) { - throw new Error(`Failed to validate changed resources for task ${taskName}: Task cache not found`); - } - if (await taskCache.matchResourceInReadCache(resource)) { - log.verbose(`Resource content has not changed for task ${taskName}, ` + - `removing ${resourcePath} from set of changed resource paths`); - changedResourcePaths.delete(resourcePath); - } - } - } - /** * Gets the set of changed project resource paths for a task * @@ -288,177 +427,166 @@ export default class ProjectBuildCache { /** * Determines whether a rebuild is needed * - * @returns {boolean} True if no cache exists or if any tasks have been invalidated + * A rebuild is required if: + * - No task cache exists + * - Any tasks have been invalidated + * - Initial build is required (e.g., cache couldn't be loaded) + * + * @returns {boolean} True if rebuild is needed, false if cache can be fully utilized */ - needsRebuild() { - return !this.hasAnyCache() || this.#invalidatedTasks.size > 0; + requiresBuild() { + return !this.hasAnyCache() || this.#invalidatedTasks.size > 0 || this.#requiresInitialBuild; } + /** + * Initializes project stages for the given tasks + * + * Creates stage names for each task and initializes them in the project. + * This must be called before task execution begins. + * + * @param {string[]} taskNames - Array of task names to initialize stages for + * @returns {Promise} + */ async setTasks(taskNames) { const stageNames = taskNames.map((taskName) => this.#getStageNameForTask(taskName)); - this.#project.setStages(stageNames); - } - - async prepareTaskExecution(taskName, dependencyReader) { - // Check cache exists and ensure it's still valid before using it - if (this.hasTaskCache(taskName)) { - // Check whether any of the relevant resources have changed - await this.validateChangedResources(taskName, this.#project.getReader(), dependencyReader); + this.#project.initStages(stageNames); - if (this.isTaskCacheValid(taskName)) { - return false; // No need to execute task, cache is valid - } - } + // TODO: Rename function? We simply use it to have a point in time right before the project is built + } - // Switch project to use cached stage as base layer - this.#project.useStage(this.#getStageNameForTask(taskName)); - return true; // Task needs to be executed + /** + * Sets the dependency reader for accessing dependency resources + * + * The dependency reader is used by tasks to access resources from project + * dependencies. Must be set before tasks that require dependencies are executed. + * + * @param {@ui5/fs/AbstractReader} dependencyReader - Reader for dependency resources + * @returns {void} + */ + setDependencyReader(dependencyReader) { + this.#dependencyReader = dependencyReader; } - // /** - // * Gets the current status of the cache for debugging and monitoring - // * - // * @returns {object} Status information including cache state and statistics - // */ - // getStatus() { - // return { - // hasCache: this.hasAnyCache(), - // totalTasks: this.#taskCache.size, - // invalidatedTasks: this.#invalidatedTasks.size, - // modifiedResourceCount: this.#updatedResources.size, - // buildSignature: this.#buildSignature, - // restoreFailed: this.#restoreFailed - // }; - // } + /** + * Signals that all tasks have completed and switches to the result stage + * + * This finalizes the build process by switching the project to use the + * final result stage containing all build outputs. + * + * @returns {void} + */ + allTasksCompleted() { + this.#project.useResultStage(); + } /** * Gets the names of all invalidated tasks * + * Invalidated tasks are those that need to be re-executed because their + * input resources have changed. + * * @returns {string[]} Array of task names that have been invalidated */ getInvalidatedTaskNames() { return Array.from(this.#invalidatedTasks.keys()); } - // ===== SERIALIZATION ===== - async #createCacheManifest() { - const cache = Object.create(null); - cache.index = await this.#createIndex(this.#project.getSourceReader(), true); - cache.indexTimestamp = Date.now(); // TODO: This is way too late if the resource' metadata has been cached - - cache.taskMetadata = Object.create(null); - for (const [taskName, taskCache] of this.#taskCache) { - cache.taskMetadata[taskName] = await taskCache.createMetadata(); - } - - cache.stages = await this.#saveCachedStages(); - return cache; - } - - async #createIndex(reader, includeInode = false) { - const resources = await reader.byGlob("/**/*"); - return await createResourceIndex(resources, includeInode); - } - - async #saveBuildManifest(buildManifest) { - buildManifest.cache = await this.#createCacheManifest(); - - await this.#cacheManager.writeBuildManifest( - this.#project, this.#buildSignature, buildManifest); - - // Import cached stages back into project to prevent inconsistent state during next build/save - await this.#importCachedStages(buildManifest.cache.stages); - } - + /** + * Generates the stage name for a given task + * + * @private + * @param {string} taskName - Name of the task + * @returns {string} Stage name in the format "task/{taskName}" + */ #getStageNameForTask(taskName) { return `task/${taskName}`; } - async #saveCachedStages() { - log.info(`Storing task outputs for project ${this.#project.getName()} in cache...`); - - return await Promise.all(this.#project.getStagesForCache().map(async ({stageId, reader}) => { - const resources = await reader.byGlob("/**/*"); - const resourceMetadata = Object.create(null); - await Promise.all(resources.map(async (res) => { - // Store resource content in cacache via CacheManager - const integrity = await this.#cacheManager.writeStage( - this.#buildSignature, stageId, - res.getOriginalPath(), await res.getBuffer() - ); + // ===== SERIALIZATION ===== - resourceMetadata[res.getOriginalPath()] = { - size: await res.getSize(), - lastModified: res.getLastModified(), - integrity, - }; - })); - return [stageId, resourceMetadata]; - })); + /** + * Loads the cached result stage from persistent storage + * + * Attempts to load a cached result stage using the resource index signature. + * If found, creates a reader for the cached stage and sets it as the project's + * result stage. + * + * @private + * @returns {Promise} True if cache was loaded successfully, false otherwise + */ + async #loadIndexCache() { + const stageSignature = this.#resourceIndex.getSignature(); + const stageId = "result"; + log.verbose(`Project ${this.#project.getName()} resource index signature: ${stageSignature}`); + const stageCache = await this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, stageId, stageSignature); + + if (!stageCache) { + log.verbose( + `No cached stage found for project ${this.#project.getName()} with index signature ${stageSignature}`); + return false; + } + log.verbose( + `Using cached result stage for project ${this.#project.getName()} with index signature ${stageSignature}`); + const reader = await this.#createReaderForStageCache( + stageId, stageSignature, stageCache.resourceMetadata); + this.#project.setResultStage(reader); + this.#project.useResultStage(); + return true; } - async #checkForIndexChanges(index, indexTimestamp) { - log.verbose(`Checking for source changes for project ${this.#project.getName()}`); - const sourceReader = this.#project.getSourceReader(); - const resources = await sourceReader.byGlob("/**/*"); - const changedResources = new Set(); - for (const resource of resources) { - const currentLastModified = resource.getLastModified(); - const resourcePath = resource.getOriginalPath(); - if (currentLastModified > indexTimestamp) { - // Resource modified after index was created, no need for further checks - log.verbose(`Source file created or modified after index creation: ${resourcePath}`); - changedResources.add(resourcePath); - continue; - } - // Check against index - if (!Object.hasOwn(index, resourcePath)) { - // New resource encountered - log.verbose(`New source file: ${resourcePath}`); - changedResources.add(resourcePath); - continue; - } - const {lastModified, size, inode, integrity} = index[resourcePath]; - - if (lastModified !== currentLastModified) { - log.verbose(`Source file modified: ${resourcePath} (timestamp change)`); - changedResources.add(resourcePath); - continue; - } - - if (inode !== resource.getInode()) { - log.verbose(`Source file modified: ${resourcePath} (inode change)`); - changedResources.add(resourcePath); - continue; - } - - if (size !== await resource.getSize()) { - log.verbose(`Source file modified: ${resourcePath} (size change)`); - changedResources.add(resourcePath); - continue; - } + /** + * Writes the result stage to persistent cache storage + * + * Collects all resources from the result stage (excluding source reader), + * stores their content via the cache manager, and writes stage metadata + * including resource information. + * + * @private + * @returns {Promise} + */ + async #writeResultStage() { + const stageSignature = this.#resourceIndex.getSignature(); + const stageId = "result"; + + const deltaReader = this.#project.getReader({excludeSourceReader: true}); + const resources = await deltaReader.byGlob("/**/*"); + const resourceMetadata = Object.create(null); + log.verbose(`Project ${this.#project.getName()} resource index signature: ${stageSignature}`); + log.verbose(`Caching result stage with ${resources.length} resources`); + + await Promise.all(resources.map(async (res) => { + // Store resource content in cacache via CacheManager + await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + + resourceMetadata[res.getOriginalPath()] = { + inode: res.getInode(), + lastModified: res.getLastModified(), + size: await res.getSize(), + integrity: await res.getIntegrity(), + }; + })); - if (currentLastModified === indexTimestamp) { - // If the source modification time is equal to index creation time, - // it's possible for a race condition to have occurred where the file was modified - // during index creation without changing its size. - // In this case, we need to perform an integrity check to determine if the file has changed. - const currentIntegrity = await resource.getIntegrity(); - if (currentIntegrity !== integrity) { - log.verbose(`Resource changed: ${resourcePath} (integrity change)`); - changedResources.add(resourcePath); - } - } - } - if (changedResources.size) { - const invalidatedTasks = this.resourceChanged(changedResources, new Set()); - if (invalidatedTasks) { - log.info(`Invalidating tasks due to changed resources for project ${this.#project.getName()}`); - } - } + const metadata = { + resourceMetadata, + }; + await this.#cacheManager.writeStageCache( + this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); } - async #createReaderForStageCache(stageId, resourceMetadata) { + /** + * Creates a proxy reader for accessing cached stage resources + * + * The reader provides virtual access to cached resources by loading them from + * the cache storage on demand. Resource metadata is used to validate cache entries. + * + * @private + * @param {string} stageId - Identifier for the stage (e.g., "result" or "task/{taskName}") + * @param {string} stageSignature - Signature hash of the stage + * @param {Object} resourceMetadata - Metadata for all cached resources + * @returns {Promise<@ui5/fs/AbstractReader>} Proxy reader for cached resources + */ + async #createReaderForStageCache(stageId, stageSignature, resourceMetadata) { const allResourcePaths = Object.keys(resourceMetadata); return createProxy({ name: `Cache reader for task ${stageId} in project ${this.#project.getName()}`, @@ -469,7 +597,7 @@ export default class ProjectBuildCache { if (!allResourcePaths.includes(virPath)) { return null; } - const {lastModified, size, integrity} = resourceMetadata[virPath]; + const {lastModified, size, integrity, inode} = resourceMetadata[virPath]; if (size === undefined || lastModified === undefined || integrity === undefined) { throw new Error(`Incomplete metadata for resource ${virPath} of task ${stageId} ` + @@ -477,11 +605,10 @@ export default class ProjectBuildCache { } // Get path to cached file contend stored in cacache via CacheManager const cachePath = await this.#cacheManager.getResourcePathForStage( - this.#buildSignature, stageId, virPath, integrity); + this.#buildSignature, stageId, stageSignature, virPath, integrity); if (!cachePath) { - log.warn(`Content of resource ${virPath} of task ${stageId} ` + + throw new Error(`Unexpected cache miss for resource ${virPath} of task ${stageId} ` + `in project ${this.#project.getName()}`); - return null; } return createResource({ path: virPath, @@ -497,40 +624,91 @@ export default class ProjectBuildCache { size, lastModified, integrity, + inode, }); } }); } - async #importCachedTasks(taskMetadata) { - for (const [taskName, metadata] of Object.entries(taskMetadata)) { - this.#taskCache.set(taskName, - new BuildTaskCache(this.#project.getName(), taskName, metadata)); + /** + * Stores all cache data to persistent storage + * + * This method: + * 1. Writes the build manifest (if not already written) + * 2. Stores the result stage with all resources + * 3. Writes the resource index and task metadata + * 4. Stores all stage caches from the queue + * + * @param {object} buildManifest - Build manifest containing metadata about the build + * @param {string} buildManifest.manifestVersion - Version of the manifest format + * @param {string} buildManifest.signature - Build signature + * @returns {Promise} + */ + async storeCache(buildManifest) { + log.verbose(`Storing build cache for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + if (!this.#buildManifest) { + this.#buildManifest = buildManifest; + await this.#cacheManager.writeBuildManifest(this.#project.getId(), this.#buildSignature, buildManifest); } - } - async #importCachedStages(stages) { - const readers = await Promise.all(stages.map(async ([stageId, resourceMetadata]) => { - return await this.#createReaderForStageCache(stageId, resourceMetadata); - })); - this.#project.setStages(stages.map(([id]) => id), readers); - } + // Store result stage + await this.#writeResultStage(); - async saveToDisk(buildManifest) { - await this.#saveBuildManifest(buildManifest); + // Store index cache + const indexMetadata = this.#resourceIndex.toCacheObject(); + const taskMetadata = Object.create(null); + for (const [taskName, taskCache] of this.#taskCache) { + taskMetadata[taskName] = taskCache.toCacheObject(); + } + await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, { + ...indexMetadata, + taskMetadata, + }); + + // Store stage caches + const stageQueue = this.#stageCache.flushCacheQueue(); + await Promise.all(stageQueue.map(async ([stageId, stageSignature]) => { + const {stage} = this.#stageCache.getCacheForSignature(stageId, stageSignature); + const writer = stage.getWriter(); + const reader = writer.collection ? writer.collection : writer; + const resources = await reader.byGlob("/**/*"); + const resourceMetadata = Object.create(null); + await Promise.all(resources.map(async (res) => { + // Store resource content in cacache via CacheManager + await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + + resourceMetadata[res.getOriginalPath()] = { + inode: res.getInode(), + lastModified: res.getLastModified(), + size: await res.getSize(), + integrity: await res.getIntegrity(), + }; + })); + + const metadata = { + resourceMetadata, + }; + await this.#cacheManager.writeStageCache( + this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); + })); } /** - * Attempts to load the cache from disk + * Loads and validates the build manifest from persistent storage * - * If a cache file exists, it will be loaded and validated. If any source files - * have changed since the cache was created, affected tasks will be invalidated. + * Attempts to load the build manifest and performs validation: + * - Checks manifest version compatibility (must be "1.0") + * - Validates build signature matches the expected signature * - * @returns {Promise} - * @throws {Error} If cache restoration fails + * If validation fails, the cache is considered invalid and will be ignored. + * + * @private + * @returns {Promise} Build manifest object or undefined if not found/invalid + * @throws {Error} If build signature mismatch or cache restoration fails */ - async #attemptLoadFromDisk() { - const manifest = await this.#cacheManager.readBuildManifest(this.#project, this.#buildSignature); + async #loadBuildManifest() { + const manifest = await this.#cacheManager.readBuildManifest(this.#project.getId(), this.#buildSignature); if (!manifest) { log.verbose(`No build manifest found for project ${this.#project.getName()} ` + `with build signature ${this.#buildSignature}`); @@ -539,7 +717,7 @@ export default class ProjectBuildCache { try { // Check build manifest version - const {buildManifest, cache} = manifest; + const {buildManifest} = manifest; if (buildManifest.manifestVersion !== "1.0") { log.verbose(`Incompatible build manifest version ${manifest.version} found for project ` + `${this.#project.getName()} with build signature ${this.#buildSignature}. Ignoring cache.`); @@ -553,18 +731,7 @@ export default class ProjectBuildCache { `Build manifest signature ${manifest.buildManifest.signature} does not match expected ` + `build signature ${this.#buildSignature} for project ${this.#project.getName()}`); } - log.info( - `Restoring build cache for project ${this.#project.getName()} from build manifest ` + - `with signature ${this.#buildSignature}`); - - // Import task- and stage metadata first and in parallel - await Promise.all([ - this.#importCachedTasks(cache.taskMetadata), - this.#importCachedStages(cache.stages), - ]); - - // After tasks have been imported, check for source changes (and potentially invalidate tasks) - await this.#checkForIndexChanges(cache.index, cache.indexTimestamp); + return buildManifest; } catch (err) { throw new Error( `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { diff --git a/packages/project/lib/build/cache/ResourceRequestGraph.js b/packages/project/lib/build/cache/ResourceRequestGraph.js new file mode 100644 index 00000000000..aac512d24b7 --- /dev/null +++ b/packages/project/lib/build/cache/ResourceRequestGraph.js @@ -0,0 +1,628 @@ +const ALLOWED_REQUEST_TYPES = new Set(["path", "patterns", "dep-path", "dep-patterns"]); + +/** + * Represents a single request with type and value + */ +export class Request { + /** + * @param {string} type - Either 'path', 'pattern', "dep-path" or "dep-pattern" + * @param {string|string[]} value - The request value (string for path types, array for pattern types) + */ + constructor(type, value) { + if (!ALLOWED_REQUEST_TYPES.has(type)) { + throw new Error(`Invalid request type: ${type}`); + } + + // Validate value type based on request type + if ((type === "path" || type === "dep-path") && typeof value !== "string") { + throw new Error(`Request type '${type}' requires value to be a string`); + } + + this.type = type; + this.value = value; + } + + /** + * Create a canonical string representation for comparison + * + * @returns {string} Canonical key in format "type:value" or "type:[pattern1,pattern2,...]" + */ + toKey() { + if (Array.isArray(this.value)) { + return `${this.type}:${JSON.stringify(this.value)}`; + } + return `${this.type}:${this.value}`; + } + + /** + * Create Request from key string + * + * @param {string} key - Key in format "type:value" or "type:[...]" + * @returns {Request} Request instance + */ + static fromKey(key) { + const colonIndex = key.indexOf(":"); + const type = key.substring(0, colonIndex); + const valueStr = key.substring(colonIndex + 1); + + // Check if value is a JSON array + if (valueStr.startsWith("[")) { + const value = JSON.parse(valueStr); + return new Request(type, value); + } + + return new Request(type, valueStr); + } + + /** + * Check equality with another Request + * + * @param {Request} other - Request to compare with + * @returns {boolean} True if requests are equal + */ + equals(other) { + if (this.type !== other.type) { + return false; + } + + if (Array.isArray(this.value) && Array.isArray(other.value)) { + if (this.value.length !== other.value.length) { + return false; + } + return this.value.every((val, idx) => val === other.value[idx]); + } + + return this.value === other.value; + } +} + +/** + * Node in the request set graph + */ +class RequestSetNode { + /** + * @param {number} id - Unique node identifier + * @param {number|null} parent - Parent node ID or null + * @param {Request[]} addedRequests - Requests added in this node (delta) + * @param {*} metadata - Associated metadata + */ + constructor(id, parent = null, addedRequests = [], metadata = {}) { + this.id = id; + this.parent = parent; // NodeId or null + this.addedRequests = new Set(addedRequests.map((r) => r.toKey())); + this.metadata = metadata; + + // Cached materialized set (lazy computed) + this._fullSetCache = null; + this._cacheValid = false; + } + + /** + * Get the full materialized set of requests for this node + * + * @param {ResourceRequestGraph} graph - The graph containing this node + * @returns {Set} Set of request keys + */ + getMaterializedSet(graph) { + if (this._cacheValid && this._fullSetCache !== null) { + return new Set(this._fullSetCache); + } + + const result = new Set(); + let current = this; + + // Walk up parent chain, collecting all added requests + while (current !== null) { + for (const requestKey of current.addedRequests) { + result.add(requestKey); + } + current = current.parent ? graph.getNode(current.parent) : null; + } + + // Cache the result + this._fullSetCache = result; + this._cacheValid = true; + + return new Set(result); + } + + /** + * Invalidate cache (called when graph structure changes) + */ + invalidateCache() { + this._cacheValid = false; + this._fullSetCache = null; + } + + /** + * Get full set as Request objects + * + * @param {ResourceRequestGraph} graph - The graph containing this node + * @returns {Request[]} Array of Request objects + */ + getMaterializedRequests(graph) { + const keys = this.getMaterializedSet(graph); + return Array.from(keys).map((key) => Request.fromKey(key)); + } + + /** + * Get only the requests added in this node (delta, not including parent requests) + * + * @returns {Request[]} Array of Request objects added in this node + */ + getAddedRequests() { + return Array.from(this.addedRequests).map((key) => Request.fromKey(key)); + } + + getParentId() { + return this.parent; + } +} + +/** + * Graph managing request set nodes with delta encoding + */ +export default class ResourceRequestGraph { + constructor() { + this.nodes = new Map(); // nodeId -> RequestSetNode + this.nextId = 1; + } + + /** + * Get a node by ID + * + * @param {number} nodeId - Node identifier + * @returns {RequestSetNode|undefined} The node or undefined if not found + */ + getNode(nodeId) { + return this.nodes.get(nodeId); + } + + /** + * Get all node IDs + * + * @returns {number[]} Array of all node IDs + */ + getAllNodeIds() { + return Array.from(this.nodes.keys()); + } + + /** + * Find the best parent for a new request set using greedy selection + * + * @param {Request[]} requestSet - Array of Request objects + * @returns {{parentId: number, deltaSize: number}|null} Parent info or null if no suitable parent + */ + findBestParent(requestSet) { + if (this.nodes.size === 0) { + return null; + } + + const requestKeys = new Set(requestSet.map((r) => r.toKey())); + let bestParent = null; + let smallestDelta = Infinity; + + // Compare against all existing nodes + for (const [nodeId, node] of this.nodes) { + const nodeSet = node.getMaterializedSet(this); + + // Calculate how many new requests would need to be added + const delta = this._calculateDelta(requestKeys, nodeSet); + + // We want the parent that minimizes the delta (maximum overlap) + if (delta < smallestDelta) { + smallestDelta = delta; + bestParent = nodeId; + } + } + + return bestParent !== null ? {parentId: bestParent, deltaSize: smallestDelta} : null; + } + + /** + * Calculate the size of the delta (requests in newSet not in existingSet) + * + * @param {Set} newSetKeys - Set of request keys + * @param {Set} existingSetKeys - Set of existing request keys + * @returns {number} Number of requests in newSet not in existingSet + */ + _calculateDelta(newSetKeys, existingSetKeys) { + let deltaCount = 0; + for (const key of newSetKeys) { + if (!existingSetKeys.has(key)) { + deltaCount++; + } + } + return deltaCount; + } + + /** + * Calculate which requests need to be added (delta) + * + * @param {Request[]} newRequestSet - New request set + * @param {Set} parentSet - Parent's materialized set (keys) + * @returns {Request[]} Array of requests to add + */ + _calculateAddedRequests(newRequestSet, parentSet) { + const newKeys = new Set(newRequestSet.map((r) => r.toKey())); + const addedKeys = []; + + for (const key of newKeys) { + if (!parentSet.has(key)) { + addedKeys.push(key); + } + } + + return addedKeys.map((key) => Request.fromKey(key)); + } + + /** + * Add a new request set to the graph + * + * @param {Request[]} requests - Array of Request objects + * @param {*} metadata - Optional metadata to store with this node + * @returns {number} The new node ID + */ + addRequestSet(requests, metadata = null) { + const nodeId = this.nextId++; + + // Find best parent + const parentInfo = this.findBestParent(requests); + + if (parentInfo === null) { + // No existing nodes, or no suitable parent - create root node + const node = new RequestSetNode(nodeId, null, requests, metadata); + this.nodes.set(nodeId, node); + return nodeId; + } + + // Create node with delta from best parent + const parentNode = this.getNode(parentInfo.parentId); + const parentSet = parentNode.getMaterializedSet(this); + const addedRequests = this._calculateAddedRequests(requests, parentSet); + + const node = new RequestSetNode(nodeId, parentInfo.parentId, addedRequests, metadata); + this.nodes.set(nodeId, node); + + return nodeId; + } + + /** + * Find the best matching node for a query request set + * Returns the node ID where the node's set is a subset of the query + * and is maximal (largest subset match) + * + * @param {Request[]} queryRequests - Array of Request objects to match + * @returns {number|null} Node ID of best match, or null if no match found + */ + findBestMatch(queryRequests) { + const queryKeys = new Set(queryRequests.map((r) => r.toKey())); + + let bestMatch = null; + let bestMatchSize = -1; + + for (const [nodeId, node] of this.nodes) { + const nodeSet = node.getMaterializedSet(this); + + // Check if nodeSet is a subset of queryKeys + const isSubset = this._isSubset(nodeSet, queryKeys); + + if (isSubset && nodeSet.size > bestMatchSize) { + bestMatch = nodeId; + bestMatchSize = nodeSet.size; + } + } + + return bestMatch; + } + + /** + * Find a node with an identical request set + * + * @param {Request[]} requests - Array of Request objects + * @returns {number|null} Node ID of exact match, or null if no match found + */ + findExactMatch(requests) { + // Convert to request keys for comparison + const queryKeys = new Set(requests.map((req) => new Request(req.type, req.value).toKey())); + + // Must have same size to be identical + const querySize = queryKeys.size; + + for (const [nodeId, node] of this.nodes) { + const nodeSet = node.getMaterializedSet(this); + + // Quick size check first + if (nodeSet.size !== querySize) { + continue; + } + + // Check if sets are identical (same size + subset = equality) + if (this._isSubset(nodeSet, queryKeys)) { + return nodeId; + } + } + + return null; + } + + /** + * Check if setA is a subset of setB + * + * @param {Set} setA - First set + * @param {Set} setB - Second set + * @returns {boolean} True if setA is a subset of setB + */ + _isSubset(setA, setB) { + for (const item of setA) { + if (!setB.has(item)) { + return false; + } + } + return true; + } + + /** + * Get metadata associated with a node + * + * @param {number} nodeId - Node identifier + * @returns {*} Metadata or null if node not found + */ + getMetadata(nodeId) { + const node = this.getNode(nodeId); + return node ? node.metadata : null; + } + + /** + * Update metadata for a node + * + * @param {number} nodeId - Node identifier + * @param {*} metadata - New metadata value + */ + setMetadata(nodeId, metadata) { + const node = this.getNode(nodeId); + if (node) { + node.metadata = metadata; + } + } + + /** + * Get a set containing all unique requests across all nodes in the graph + * + * @returns {Request[]} Array of all unique Request objects in the graph + */ + getAllRequests() { + const allRequestKeys = new Set(); + + for (const node of this.nodes.values()) { + const nodeSet = node.getMaterializedSet(this); + for (const key of nodeSet) { + allRequestKeys.add(key); + } + } + + return Array.from(allRequestKeys).map((key) => Request.fromKey(key)); + } + + /** + * Get statistics about the graph + */ + getStats() { + let totalRequests = 0; + let totalStoredDeltas = 0; + const depths = []; + + for (const node of this.nodes.values()) { + totalRequests += node.getMaterializedSet(this).size; + totalStoredDeltas += node.addedRequests.size; + + // Calculate depth + let depth = 0; + let current = node; + while (current.parent !== null) { + depth++; + current = this.getNode(current.parent); + } + depths.push(depth); + } + + return { + nodeCount: this.nodes.size, + averageRequestsPerNode: this.nodes.size > 0 ? totalRequests / this.nodes.size : 0, + averageStoredDeltaSize: this.nodes.size > 0 ? totalStoredDeltas / this.nodes.size : 0, + averageDepth: depths.length > 0 ? depths.reduce((a, b) => a + b, 0) / depths.length : 0, + maxDepth: depths.length > 0 ? Math.max(...depths) : 0, + compressionRatio: totalRequests > 0 ? totalStoredDeltas / totalRequests : 1 + }; + } + + /** + * Iterate through nodes in breadth-first order (by depth level). + * Parents are always yielded before their children, allowing efficient traversal + * where you can check parent nodes first and only examine deltas of subtrees as needed. + * + * @yields {{nodeId: number, node: RequestSetNode, depth: number, parentId: number|null}} + * Node information including ID, node instance, depth level, and parent ID + * + * @example + * // Traverse all nodes, checking parents before children + * for (const {nodeId, node, depth, parentId} of graph.traverseByDepth()) { + * const delta = node.getAddedRequests(); + * const fullSet = node.getMaterializedRequests(graph); + * console.log(`Node ${nodeId} at depth ${depth}: +${delta.length} requests`); + * } + * + * @example + * // Early termination: find first matching node without processing children + * for (const {nodeId, node} of graph.traverseByDepth()) { + * if (nodeMatchesQuery(node)) { + * console.log(`Found match at node ${nodeId}`); + * break; // Stop traversal + * } + * } + */ + * traverseByDepth() { + if (this.nodes.size === 0) { + return; + } + + // Build children map for efficient traversal + const childrenMap = new Map(); // parentId -> [childIds] + const rootNodes = []; + + for (const [nodeId, node] of this.nodes) { + if (node.parent === null) { + rootNodes.push(nodeId); + } else { + if (!childrenMap.has(node.parent)) { + childrenMap.set(node.parent, []); + } + childrenMap.get(node.parent).push(nodeId); + } + } + + // Breadth-first traversal using a queue + const queue = rootNodes.map((nodeId) => ({nodeId, depth: 0})); + + while (queue.length > 0) { + const {nodeId, depth} = queue.shift(); + const node = this.getNode(nodeId); + + // Yield current node + yield { + nodeId, + node, + depth, + parentId: node.parent + }; + + // Enqueue children for next depth level + const children = childrenMap.get(nodeId); + if (children) { + for (const childId of children) { + queue.push({nodeId: childId, depth: depth + 1}); + } + } + } + } + + /** + * Iterate through nodes starting from a specific node, traversing its subtree. + * Useful for examining only a portion of the graph. + * + * @param {number} startNodeId - Node ID to start traversal from + * @yields {{nodeId: number, node: RequestSetNode, depth: number, parentId: number|null}} + * Node information including ID, node instance, relative depth from start, and parent ID + * + * @example + * // Traverse only the subtree under a specific node + * const matchNodeId = graph.findBestMatch(query); + * for (const {nodeId, node, depth} of graph.traverseSubtree(matchNodeId)) { + * console.log(`Processing node ${nodeId} at relative depth ${depth}`); + * } + */ + * traverseSubtree(startNodeId) { + const startNode = this.getNode(startNodeId); + if (!startNode) { + return; + } + + // Build children map + const childrenMap = new Map(); + for (const [nodeId, node] of this.nodes) { + if (node.parent !== null) { + if (!childrenMap.has(node.parent)) { + childrenMap.set(node.parent, []); + } + childrenMap.get(node.parent).push(nodeId); + } + } + + // Breadth-first traversal starting from the specified node + const queue = [{nodeId: startNodeId, depth: 0}]; + + while (queue.length > 0) { + const {nodeId, depth} = queue.shift(); + const node = this.getNode(nodeId); + + yield { + nodeId, + node, + depth, + parentId: node.parent + }; + + // Enqueue children + const children = childrenMap.get(nodeId); + if (children) { + for (const childId of children) { + queue.push({nodeId: childId, depth: depth + 1}); + } + } + } + } + + /** + * Get all children node IDs for a given parent node + * + * @param {number} parentId - Parent node identifier + * @returns {number[]} Array of child node IDs + */ + getChildren(parentId) { + const children = []; + for (const [nodeId, node] of this.nodes) { + if (node.parent === parentId) { + children.push(nodeId); + } + } + return children; + } + + /** + * Export graph structure for serialization + * + * @returns {{nodes: Array<{id: number, parent: number|null, addedRequests: string[]}>, nextId: number}} + * Graph structure with metadata + */ + toCacheObject() { + const nodes = []; + + for (const [nodeId, node] of this.nodes) { + nodes.push({ + id: nodeId, + parent: node.parent, + addedRequests: Array.from(node.addedRequests) + }); + } + + return {nodes, nextId: this.nextId}; + } + + /** + * Create a graph from JSON structure (as produced by toCacheObject) + * + * @param {{nodes: Array<{id: number, parent: number|null, addedRequests: string[]}>, nextId: number}} metadata + * JSON representation of the graph + * @returns {ResourceRequestGraph} Reconstructed graph instance + */ + static fromCacheObject(metadata) { + const graph = new ResourceRequestGraph(); + + // Restore nextId + graph.nextId = metadata.nextId; + + // Recreate all nodes + for (const nodeData of metadata.nodes) { + const {id, parent, addedRequests} = nodeData; + + // Convert request keys back to Request instances + const requestInstances = addedRequests.map((key) => Request.fromKey(key)); + + // Create node directly + const node = new RequestSetNode(id, parent, requestInstances); + graph.nodes.set(id, node); + } + + return graph; + } +} diff --git a/packages/project/lib/build/cache/StageCache.js b/packages/project/lib/build/cache/StageCache.js new file mode 100644 index 00000000000..519531720c6 --- /dev/null +++ b/packages/project/lib/build/cache/StageCache.js @@ -0,0 +1,89 @@ +/** + * @typedef {object} StageCacheEntry + * @property {object} stage - The cached stage instance (typically a reader or writer) + * @property {Set} writtenResourcePaths - Set of resource paths written during stage execution + */ + +/** + * In-memory cache for build stage results + * + * Manages cached build stages by their signatures, allowing quick lookup and reuse + * of previously executed build stages. Each stage is identified by a stage ID + * (e.g., "task/taskName") and a signature (content hash of input resources). + * + * The cache maintains a queue of added signatures that need to be persisted, + * enabling batch writes to persistent storage. + * + * Key features: + * - Fast in-memory lookup by stage ID and signature + * - Tracks written resources for cache invalidation + * - Supports batch persistence via flush queue + * - Multiple signatures per stage ID (for different input combinations) + */ +export default class StageCache { + #stageIdToSignatures = new Map(); + #cacheQueue = []; + + /** + * Adds a stage signature to the cache + * + * Stores the stage instance and its written resources under the given stage ID + * and signature. The signature is added to the flush queue for later persistence. + * + * Multiple signatures can exist for the same stage ID, representing different + * input resource combinations that produce different outputs. + * + * @param {string} stageId - Identifier for the stage (e.g., "task/generateBundle") + * @param {string} signature - Content hash signature of the stage's input resources + * @param {object} stageInstance - The stage instance to cache (typically a reader or writer) + * @param {Set} writtenResourcePaths - Set of resource paths written during this stage + * @returns {void} + */ + addSignature(stageId, signature, stageInstance, writtenResourcePaths) { + if (!this.#stageIdToSignatures.has(stageId)) { + this.#stageIdToSignatures.set(stageId, new Map()); + } + const signatureToStageInstance = this.#stageIdToSignatures.get(stageId); + signatureToStageInstance.set(signature, { + stage: stageInstance, + writtenResourcePaths, + }); + this.#cacheQueue.push([stageId, signature]); + } + + /** + * Retrieves cached stage data for a specific signature + * + * Looks up a previously cached stage by its ID and signature. Returns null + * if either the stage ID or signature is not found in the cache. + * + * @param {string} stageId - Identifier for the stage to look up + * @param {string} signature - Signature hash to match + * @returns {StageCacheEntry|null} Cached stage entry with stage instance and written paths, + * or null if not found + */ + getCacheForSignature(stageId, signature) { + if (!this.#stageIdToSignatures.has(stageId)) { + return null; + } + const signatureToStageInstance = this.#stageIdToSignatures.get(stageId); + return signatureToStageInstance.get(signature) || null; + } + + /** + * Retrieves and clears the cache queue + * + * Returns all stage signatures that have been added since the last flush, + * then resets the queue. The returned entries should be persisted to storage. + * + * Each queue entry is a tuple of [stageId, signature] that can be used to + * retrieve the full stage data via getCacheForSignature(). + * + * @returns {Array<[string, string]>} Array of [stageId, signature] tuples that need persistence + */ + flushCacheQueue() { + const queue = this.#cacheQueue; + this.#cacheQueue = []; + return queue; + } +} diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js new file mode 100644 index 00000000000..b1678bb41a9 --- /dev/null +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -0,0 +1,1103 @@ +import crypto from "node:crypto"; +import path from "node:path/posix"; +import {matchResourceMetadataStrict} from "../utils.js"; + +/** + * @typedef {object} @ui5/project/build/cache/index/HashTree~ResourceMetadata + * @property {number} size - File size in bytes + * @property {number} lastModified - Last modification timestamp + * @property {number|undefined} inode - File inode identifier + * @property {string} integrity - Content hash + */ + +/** + * Represents a node in the directory-based Merkle tree + */ +class TreeNode { + constructor(name, type, options = {}) { + this.name = name; // resource name or directory name + this.type = type; // 'resource' | 'directory' + this.hash = options.hash || null; // Buffer + + // Resource node properties + this.integrity = options.integrity; // Resource content hash + this.lastModified = options.lastModified; // Last modified timestamp + this.size = options.size; // File size in bytes + this.inode = options.inode; // File system inode number + + // Directory node properties + this.children = options.children || new Map(); // name -> TreeNode + } + + /** + * Get full path from root to this node + * + * @param {string} parentPath + * @returns {string} + */ + getPath(parentPath = "") { + return parentPath ? path.join(parentPath, this.name) : this.name; + } + + /** + * Serialize to JSON + * + * @returns {object} + */ + toJSON() { + const obj = { + name: this.name, + type: this.type, + hash: this.hash ? this.hash.toString("hex") : null + }; + + if (this.type === "resource") { + obj.integrity = this.integrity; + obj.lastModified = this.lastModified; + obj.size = this.size; + obj.inode = this.inode; + } else { + obj.children = {}; + for (const [name, child] of this.children) { + obj.children[name] = child.toJSON(); + } + } + + return obj; + } + + /** + * Deserialize from JSON + * + * @param {object} data + * @returns {TreeNode} + */ + static fromJSON(data) { + const options = { + hash: data.hash ? Buffer.from(data.hash, "hex") : null, + integrity: data.integrity, + lastModified: data.lastModified, + size: data.size, + inode: data.inode + }; + + if (data.type === "directory" && data.children) { + options.children = new Map(); + for (const [name, childData] of Object.entries(data.children)) { + options.children.set(name, TreeNode.fromJSON(childData)); + } + } + + return new TreeNode(data.name, data.type, options); + } + + /** + * Create a deep copy of this node + * + * @returns {TreeNode} + */ + clone() { + const options = { + hash: this.hash ? Buffer.from(this.hash) : null, + integrity: this.integrity, + lastModified: this.lastModified, + size: this.size, + inode: this.inode + }; + + if (this.type === "directory") { + options.children = new Map(); + for (const [name, child] of this.children) { + options.children.set(name, child.clone()); + } + } + + return new TreeNode(this.name, this.type, options); + } +} + +/** + * Directory-based Merkle Tree for efficient resource tracking with hierarchical structure. + * + * Computes deterministic SHA256 hashes for resources and directories, enabling: + * - Fast change detection via root hash comparison + * - Structural sharing through derived trees (memory efficient) + * - Coordinated multi-tree updates via TreeRegistry + * - Batch upsert and removal operations + * + * Primary use case: Build caching systems where multiple related resource trees + * (e.g., source files, build artifacts) need to be tracked and synchronized efficiently. + */ +export default class HashTree { + #indexTimestamp; + /** + * Create a new HashTree + * + * @param {Array|null} resources + * Initial resources to populate the tree. Each resource should have a path and optional metadata. + * @param {object} options + * @param {TreeRegistry} [options.registry] - Optional registry for coordinated batch updates across multiple trees + * @param {number} [options.indexTimestamp] - Timestamp when the resource index was created (for metadata comparison) + * @param {TreeNode} [options._root] - Internal: pre-existing root node for derived trees (enables structural sharing) + */ + constructor(resources = null, options = {}) { + this.registry = options.registry || null; + this.root = options._root || new TreeNode("", "directory"); + this.#indexTimestamp = options.indexTimestamp || Date.now(); + + // Register with registry if provided + if (this.registry) { + this.registry.register(this); + } + + if (resources && !options._root) { + this._buildTree(resources); + } else if (resources && options._root) { + // Derived tree: insert additional resources into shared structure + for (const resource of resources) { + this._insertResourceWithSharing(resource.path, resource); + } + // Recompute hashes for newly added paths + this._computeHash(this.root); + } + } + + /** + * Shallow copy a directory node (copies node, shares children) + * + * @param {TreeNode} dirNode + * @returns {TreeNode} + * @private + */ + _shallowCopyDirectory(dirNode) { + if (dirNode.type !== "directory") { + throw new Error("Can only shallow copy directory nodes"); + } + + const copy = new TreeNode(dirNode.name, "directory", { + hash: dirNode.hash ? Buffer.from(dirNode.hash) : null, + children: new Map(dirNode.children) // Shallow copy of Map (shares TreeNode references) + }); + + return copy; + } + + /** + * Build tree from resource list + * + * @param {Array<{path: string, integrity?: string}>} resources + * @private + */ + _buildTree(resources) { + // Sort resources by path for deterministic ordering + const sortedResources = [...resources].sort((a, b) => a.path.localeCompare(b.path)); + + // Insert each resource into the tree + for (const resource of sortedResources) { + this._insertResource(resource.path, resource); + } + + // Compute all hashes bottom-up + this._computeHash(this.root); + } + + /** + * Insert a resource with structural sharing for derived trees + * Implements copy-on-write: only copies directories that will be modified + * + * Key optimization: When adding "a/b/c/file.js", only copies: + * - Directory "c" (will get new child) + * Directories "a" and "b" remain shared references if they existed. + * + * This preserves memory efficiency when derived trees have different + * resources in some paths but share others. + * + * @param {string} resourcePath + * @param {object} resourceData + * @private + */ + _insertResourceWithSharing(resourcePath, resourceData) { + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + let current = this.root; + const pathToCopy = []; // Track path that needs copy-on-write + + // Phase 1: Navigate to find where we need to start copying + for (let i = 0; i < parts.length - 1; i++) { + const dirName = parts[i]; + + if (!current.children.has(dirName)) { + // New directory needed - we'll create from here + break; + } + + const existing = current.children.get(dirName); + if (existing.type !== "directory") { + throw new Error(`Path conflict: ${dirName} exists as resource but expected directory`); + } + + pathToCopy.push({parent: current, dirName, node: existing}); + current = existing; + } + + // Phase 2: Copy path from root down (copy-on-write) + // Only copy directories that will have their children modified + current = this.root; + let needsNewChild = false; + + for (let i = 0; i < parts.length - 1; i++) { + const dirName = parts[i]; + + if (!current.children.has(dirName)) { + // Create new directory from here + const newDir = new TreeNode(dirName, "directory"); + current.children.set(dirName, newDir); + current = newDir; + needsNewChild = true; + } else if (i === parts.length - 2) { + // This is the parent directory that will get the new resource + // Copy it to avoid modifying shared structure + const existing = current.children.get(dirName); + const copiedDir = this._shallowCopyDirectory(existing); + current.children.set(dirName, copiedDir); + current = copiedDir; + } else { + // Just traverse - don't copy intermediate directories + // They remain shared with the source tree (structural sharing) + current = current.children.get(dirName); + } + } + + // Insert the resource + const resourceName = parts[parts.length - 1]; + + if (current.children.has(resourceName)) { + throw new Error(`Duplicate resource path: ${resourcePath}`); + } + + const resourceNode = new TreeNode(resourceName, "resource", { + integrity: resourceData.integrity, + lastModified: resourceData.lastModified, + size: resourceData.size, + inode: resourceData.inode + }); + + current.children.set(resourceName, resourceNode); + } + + /** + * Insert a resource into the directory tree + * + * @param {string} resourcePath + * @param {object} resourceData + * @param {string} [resourceData.integrity] - Content hash for regular resources + * @param {number} [resourceData.lastModified] - Last modified timestamp + * @param {number} [resourceData.size] - File size in bytes + * @param {number} [resourceData.inode] - File system inode number + * @private + */ + _insertResource(resourcePath, resourceData) { + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + let current = this.root; + + // Navigate/create directory structure + for (let i = 0; i < parts.length - 1; i++) { + const dirName = parts[i]; + + if (!current.children.has(dirName)) { + current.children.set(dirName, new TreeNode(dirName, "directory")); + } + + current = current.children.get(dirName); + + if (current.type !== "directory") { + throw new Error(`Path conflict: ${dirName} exists as resource but expected directory`); + } + } + + // Insert the resource + const resourceName = parts[parts.length - 1]; + + if (current.children.has(resourceName)) { + throw new Error(`Duplicate resource path: ${resourcePath}`); + } + + const resourceNode = new TreeNode(resourceName, "resource", { + integrity: resourceData.integrity, + lastModified: resourceData.lastModified, + size: resourceData.size, + inode: resourceData.inode + }); + + current.children.set(resourceName, resourceNode); + } + + /** + * Compute hash for a node and all its children (recursive) + * + * @param {TreeNode} node + * @returns {Buffer} + * @private + */ + _computeHash(node) { + if (node.type === "resource") { + // Resource hash + node.hash = this._hashData(`resource:${node.name}:${node.integrity}`); + } else { + // Directory hash - compute from sorted children + const childHashes = []; + + // Sort children by name for deterministic ordering + const sortedChildren = Array.from(node.children.entries()) + .sort((a, b) => a[0].localeCompare(b[0])); + + for (const [, child] of sortedChildren) { + this._computeHash(child); // Recursively compute child hashes + childHashes.push(child.hash); + } + + // Combine all child hashes + if (childHashes.length === 0) { + // Empty directory + node.hash = this._hashData(`dir:${node.name}:empty`); + } else { + const combined = Buffer.concat(childHashes); + node.hash = crypto.createHash("sha256") + .update(`dir:${node.name}:`) + .update(combined) + .digest(); + } + } + + return node.hash; + } + + /** + * Hash a string + * + * @param {string} data + * @returns {Buffer} + * @private + */ + _hashData(data) { + return crypto.createHash("sha256").update(data).digest(); + } + + /** + * Get the root hash as a hex string + * + * @returns {string} + */ + getRootHash() { + if (!this.root.hash) { + this._computeHash(this.root); + } + return this.root.hash.toString("hex"); + } + + /** + * Get the index timestamp + * + * @returns {number} + */ + getIndexTimestamp() { + return this.#indexTimestamp; + } + + /** + * Find a node by path + * + * @param {string} resourcePath + * @returns {TreeNode|null} + * @private + */ + _findNode(resourcePath) { + if (!resourcePath || resourcePath === "" || resourcePath === ".") { + return this.root; + } + + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + let current = this.root; + + for (const part of parts) { + if (!current.children.has(part)) { + return null; + } + current = current.children.get(part); + } + + return current; + } + + /** + * Create a derived tree that shares subtrees with this tree. + * + * Derived trees are filtered views on shared data - they share node references with the parent tree, + * enabling efficient memory usage. Changes propagate through the TreeRegistry to all derived trees. + * + * Use case: Represent different resource sets (e.g., debug vs. production builds) that share common files. + * + * @param {Array} additionalResources + * Resources to add to the derived tree (in addition to shared resources from parent) + * @returns {HashTree} New tree sharing subtrees with this tree + */ + deriveTree(additionalResources = []) { + // Shallow copy root to allow adding new top-level directories + const derivedRoot = this._shallowCopyDirectory(this.root); + + // Create derived tree with shared root and same registry + const derived = new HashTree(additionalResources, { + registry: this.registry, + _root: derivedRoot + }); + + return derived; + } + + /** + * Update a single resource and recompute affected hashes. + * + * When a registry is attached, schedules the update for batch processing. + * Otherwise, applies the update immediately and recomputes ancestor hashes. + * Skips update if resource metadata hasn't changed (optimization). + * + * @param {@ui5/fs/Resource} resource - Resource instance to update + * @returns {Promise>} Array containing the resource path if changed, empty array if unchanged + */ + async updateResource(resource) { + const resourcePath = resource.getOriginalPath(); + + // If registry is attached, schedule update instead of applying immediately + if (this.registry) { + this.registry.scheduleUpdate(resource); + return [resourcePath]; // Will be determined after flush + } + + // Fall back to immediate update + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + + if (parts.length === 0) { + throw new Error("Cannot update root directory"); + } + + // Navigate to parent directory + let current = this.root; + const pathToRoot = [current]; + + for (let i = 0; i < parts.length - 1; i++) { + const dirName = parts[i]; + + if (!current.children.has(dirName)) { + throw new Error(`Directory not found: ${parts.slice(0, i + 1).join("/")}`); + } + + current = current.children.get(dirName); + pathToRoot.push(current); + } + + // Update the resource + const resourceName = parts[parts.length - 1]; + const resourceNode = current.children.get(resourceName); + + if (!resourceNode) { + throw new Error(`Resource not found: ${resourcePath}`); + } + + if (resourceNode.type !== "resource") { + throw new Error(`Path is not a resource: ${resourcePath}`); + } + + // Create metadata object from current node state + const currentMetadata = { + integrity: resourceNode.integrity, + lastModified: resourceNode.lastModified, + size: resourceNode.size, + inode: resourceNode.inode + }; + + // Check whether resource actually changed + const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); + if (isUnchanged) { + return []; // No change + } + + // Update resource metadata + resourceNode.integrity = await resource.getIntegrity(); + resourceNode.lastModified = resource.getLastModified(); + resourceNode.size = await resource.getSize(); + resourceNode.inode = resource.getInode(); + + // Recompute hashes from resource up to root + this._computeHash(resourceNode); + + for (let i = pathToRoot.length - 1; i >= 0; i--) { + this._computeHash(pathToRoot[i]); + } + + return [resourcePath]; + } + + /** + * Update multiple resources efficiently. + * + * When a registry is attached, schedules updates for batch processing. + * Otherwise, updates all resources immediately, collecting affected directories + * and recomputing hashes bottom-up for optimal performance. + * + * Skips resources whose metadata hasn't changed (optimization). + * + * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to update + * @returns {Promise>} Paths of resources that actually changed + */ + async updateResources(resources) { + if (!resources || resources.length === 0) { + return []; + } + + const changedResources = []; + const affectedPaths = new Set(); + + // Update all resources and collect affected directory paths + for (const resource of resources) { + const resourcePath = resource.getOriginalPath(); + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + + // Find the resource node + const node = this._findNode(resourcePath); + if (!node || node.type !== "resource") { + throw new Error(`Resource not found: ${resourcePath}`); + } + + // Create metadata object from current node state + const currentMetadata = { + integrity: node.integrity, + lastModified: node.lastModified, + size: node.size, + inode: node.inode + }; + + // Check whether resource actually changed + const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); + if (isUnchanged) { + continue; // Skip unchanged resources + } + + // Update resource metadata + node.integrity = await resource.getIntegrity(); + node.lastModified = resource.getLastModified(); + node.size = await resource.getSize(); + node.inode = resource.getInode(); + changedResources.push(resourcePath); + + // Recompute resource hash + this._computeHash(node); + + // Mark all ancestor directories as needing recomputation + for (let i = 0; i < parts.length; i++) { + affectedPaths.add(parts.slice(0, i).join(path.sep)); + } + } + + // Recompute directory hashes bottom-up + const sortedPaths = Array.from(affectedPaths).sort((a, b) => { + // Sort by depth (deeper first) and then alphabetically + const depthA = a.split(path.sep).length; + const depthB = b.split(path.sep).length; + if (depthA !== depthB) return depthB - depthA; + return a.localeCompare(b); + }); + + for (const dirPath of sortedPaths) { + const node = this._findNode(dirPath); + if (node && node.type === "directory") { + this._computeHash(node); + } + } + + return changedResources; + } + + /** + * Upsert multiple resources (insert if new, update if exists). + * + * Intelligently determines whether each resource is new (insert) or existing (update). + * When a registry is attached, schedules operations for batch processing. + * Otherwise, applies operations immediately with optimized hash recomputation. + * + * Automatically creates missing parent directories during insertion. + * Skips resources whose metadata hasn't changed (optimization). + * + * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to upsert + * @returns {Promise<{added: Array, updated: Array, unchanged: Array, scheduled?: Array}>} + * Status report: arrays of paths by operation type. 'scheduled' is present when using registry. + */ + async upsertResources(resources) { + if (!resources || resources.length === 0) { + return {added: [], updated: [], unchanged: []}; + } + + if (this.registry) { + for (const resource of resources) { + this.registry.scheduleUpsert(resource); + } + // When using registry, actual results are determined during flush + return { + added: [], + updated: [], + unchanged: [], + scheduled: resources.map((r) => r.getOriginalPath()) + }; + } + + // Immediate mode + const added = []; + const updated = []; + const unchanged = []; + const affectedPaths = new Set(); + + for (const resource of resources) { + const resourcePath = resource.getOriginalPath(); + const existingNode = this.getResourceByPath(resourcePath); + + if (!existingNode) { + // Insert new resource + const resourceData = { + integrity: await resource.getIntegrity(), + lastModified: resource.getLastModified(), + size: await resource.getSize(), + inode: resource.getInode() + }; + this._insertResource(resourcePath, resourceData); + + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + const resourceNode = this._findNode(resourcePath); + this._computeHash(resourceNode); + + added.push(resourcePath); + + // Mark ancestors for recomputation + for (let i = 0; i < parts.length; i++) { + affectedPaths.add(parts.slice(0, i).join(path.sep)); + } + } else { + // Check if unchanged + const currentMetadata = { + integrity: existingNode.integrity, + lastModified: existingNode.lastModified, + size: existingNode.size, + inode: existingNode.inode + }; + + const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); + if (isUnchanged) { + unchanged.push(resourcePath); + continue; + } + + // Update existing resource + existingNode.integrity = await resource.getIntegrity(); + existingNode.lastModified = resource.getLastModified(); + existingNode.size = await resource.getSize(); + existingNode.inode = resource.getInode(); + + this._computeHash(existingNode); + updated.push(resourcePath); + + // Mark ancestors for recomputation + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + for (let i = 0; i < parts.length; i++) { + affectedPaths.add(parts.slice(0, i).join(path.sep)); + } + } + } + + // Recompute directory hashes bottom-up + const sortedPaths = Array.from(affectedPaths).sort((a, b) => { + const depthA = a ? a.split(path.sep).length : 0; + const depthB = b ? b.split(path.sep).length : 0; + if (depthA !== depthB) return depthB - depthA; + return a.localeCompare(b); + }); + + for (const dirPath of sortedPaths) { + const node = this._findNode(dirPath); + if (node && node.type === "directory") { + this._computeHash(node); + } + } + + return {added, updated, unchanged}; + } + + /** + * Remove multiple resources efficiently. + * + * When a registry is attached, schedules removals for batch processing. + * Otherwise, removes resources immediately and recomputes affected ancestor hashes. + * + * Note: When using a registry with derived trees, removals propagate to all trees + * sharing the affected directories (intentional for the shared view model). + * + * @param {Array} resourcePaths - Array of resource paths to remove + * @returns {Promise<{removed: Array, notFound: Array, scheduled?: Array}>} + * Status report: 'removed' contains successfully removed paths, 'notFound' contains paths that didn't exist. + * 'scheduled' is present when using registry. + */ + async removeResources(resourcePaths) { + if (!resourcePaths || resourcePaths.length === 0) { + return {removed: [], notFound: []}; + } + + if (this.registry) { + for (const resourcePath of resourcePaths) { + this.registry.scheduleRemoval(resourcePath); + } + return { + removed: [], + notFound: [], + scheduled: resourcePaths + }; + } + + // Immediate mode + const removed = []; + const notFound = []; + const affectedPaths = new Set(); + + for (const resourcePath of resourcePaths) { + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + + if (parts.length === 0) { + throw new Error("Cannot remove root"); + } + + // Navigate to parent + let current = this.root; + let pathExists = true; + for (let i = 0; i < parts.length - 1; i++) { + if (!current.children.has(parts[i])) { + pathExists = false; + break; + } + current = current.children.get(parts[i]); + } + + if (!pathExists) { + notFound.push(resourcePath); + continue; + } + + // Remove resource + const resourceName = parts[parts.length - 1]; + const wasRemoved = current.children.delete(resourceName); + + if (wasRemoved) { + removed.push(resourcePath); + // Mark ancestors for recomputation + for (let i = 0; i < parts.length; i++) { + affectedPaths.add(parts.slice(0, i).join(path.sep)); + } + } else { + notFound.push(resourcePath); + } + } + + // Recompute directory hashes bottom-up + const sortedPaths = Array.from(affectedPaths).sort((a, b) => { + const depthA = a ? a.split(path.sep).length : 0; + const depthB = b ? b.split(path.sep).length : 0; + if (depthA !== depthB) return depthB - depthA; + return a.localeCompare(b); + }); + + for (const dirPath of sortedPaths) { + const node = this._findNode(dirPath); + if (node && node.type === "directory") { + this._computeHash(node); + } + } + + return {removed, notFound}; + } + + /** + * Recompute hashes for all ancestor directories up to root. + * + * Used after modifications to ensure the entire path from the modified + * resource/directory up to the root has correct hash values. + * + * @param {string} resourcePath - Path to resource or directory that was modified + * @private + */ + _recomputeAncestorHashes(resourcePath) { + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + + // Recompute from deepest to root + for (let i = parts.length; i >= 0; i--) { + const dirPath = parts.slice(0, i).join(path.sep); + const node = this._findNode(dirPath); + if (node && node.type === "directory") { + this._computeHash(node); + } + } + } + + /** + * Get hash for a specific directory. + * + * Useful for checking if a specific subtree has changed without comparing the entire tree. + * + * @param {string} dirPath - Path to directory + * @returns {string} Directory hash as hex string + * @throws {Error} If path not found or path is not a directory + */ + getDirectoryHash(dirPath) { + const node = this._findNode(dirPath); + if (!node) { + throw new Error(`Path not found: ${dirPath}`); + } + if (node.type !== "directory") { + throw new Error(`Path is not a directory: ${dirPath}`); + } + return node.hash.toString("hex"); + } + + /** + * Check if a directory's contents have changed by comparing hashes. + * + * Efficient way to detect changes in a subtree without comparing individual files. + * + * @param {string} dirPath - Path to directory + * @param {string} previousHash - Previous hash to compare against + * @returns {boolean} true if directory contents changed, false otherwise + */ + hasDirectoryChanged(dirPath, previousHash) { + const currentHash = this.getDirectoryHash(dirPath); + return currentHash !== previousHash; + } + + /** + * Get all resources in a directory (non-recursive). + * + * Useful for inspecting directory contents or performing directory-level operations. + * + * @param {string} dirPath - Path to directory + * @returns {Array<{name: string, path: string, type: string, hash: string}>} Array of directory entries sorted by name + * @throws {Error} If directory not found or path is not a directory + */ + listDirectory(dirPath) { + const node = this._findNode(dirPath); + if (!node) { + throw new Error(`Directory not found: ${dirPath}`); + } + if (node.type !== "directory") { + throw new Error(`Path is not a directory: ${dirPath}`); + } + + const items = []; + for (const [name, child] of node.children) { + items.push({ + name, + path: path.join(dirPath, name), + type: child.type, + hash: child.hash.toString("hex") + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Get all resources recursively. + * + * Returns complete resource metadata including paths, integrity hashes, and file stats. + * Useful for full tree inspection or export. + * + * @returns {Array<{path: string, integrity?: string, hash: string, lastModified?: number, size?: number, inode?: number}>} + * Array of all resources with metadata, sorted by path + */ + getAllResources() { + const resources = []; + + const traverse = (node, currentPath) => { + if (node.type === "resource") { + resources.push({ + path: currentPath, + integrity: node.integrity, + hash: node.hash.toString("hex"), + lastModified: node.lastModified, + size: node.size, + inode: node.inode + }); + } else { + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + traverse(child, childPath); + } + } + }; + + traverse(this.root, "/"); + return resources.sort((a, b) => a.path.localeCompare(b.path)); + } + + /** + * Get tree statistics. + * + * Provides summary information about tree size and structure. + * + * @returns {{resources: number, directories: number, maxDepth: number, rootHash: string}} + * Statistics object with counts and root hash + */ + getStats() { + let resourceCount = 0; + let dirCount = 0; + let maxDepth = 0; + + const traverse = (node, depth) => { + maxDepth = Math.max(maxDepth, depth); + + if (node.type === "resource") { + resourceCount++; + } else { + dirCount++; + for (const child of node.children.values()) { + traverse(child, depth + 1); + } + } + }; + + traverse(this.root, 0); + + return { + resources: resourceCount, + directories: dirCount, + maxDepth, + rootHash: this.getRootHash() + }; + } + + /** + * Serialize tree to JSON + * + * @returns {object} + */ + toCacheObject() { + return { + version: 1, + root: this.root.toJSON(), + }; + } + + /** + * Deserialize tree from JSON + * + * @param {object} data + * @param {object} [options] + * @returns {HashTree} + */ + static fromCache(data, options = {}) { + if (data.version !== 1) { + throw new Error(`Unsupported version: ${data.version}`); + } + + const tree = new HashTree(null, options); + tree.root = TreeNode.fromJSON(data.root); + + return tree; + } + + /** + * Validate tree structure and hashes + * + * @returns {boolean} + */ + validate() { + const errors = []; + + const validateNode = (node, currentPath) => { + // Recompute hash + const originalHash = node.hash; + this._computeHash(node); + + if (!originalHash.equals(node.hash)) { + errors.push(`Hash mismatch at ${currentPath || "root"}`); + } + + // Restore original (in case validation is non-destructive) + node.hash = originalHash; + + // Recurse for directories + if (node.type === "directory") { + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + validateNode(child, childPath); + } + } + }; + + validateNode(this.root, ""); + + if (errors.length > 0) { + throw new Error(`Validation failed:\n${errors.join("\n")}`); + } + + return true; + } + /** + * Create a deep clone of this tree. + * + * Unlike deriveTree(), this creates a completely independent copy + * with no shared node references. + * + * @returns {HashTree} New independent tree instance + */ + clone() { + const cloned = new HashTree(); + cloned.root = this.root.clone(); + return cloned; + } + + /** + * Get resource node by path + * + * @param {string} resourcePath + * @returns {TreeNode|null} + */ + getResourceByPath(resourcePath) { + const node = this._findNode(resourcePath); + return node && node.type === "resource" ? node : null; + } + + /** + * Check if a path exists in the tree + * + * @param {string} resourcePath + * @returns {boolean} + */ + hasPath(resourcePath) { + return this._findNode(resourcePath) !== null; + } + + /** + * Get all resource paths in sorted order + * + * @returns {Array} + */ + getResourcePaths() { + const paths = []; + + const traverse = (node, currentPath) => { + if (node.type === "resource") { + paths.push(currentPath); + } else { + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + traverse(child, childPath); + } + } + }; + + traverse(this.root, "/"); + return paths.sort(); + } +} diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js new file mode 100644 index 00000000000..b2b62448617 --- /dev/null +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -0,0 +1,233 @@ +/** + * @module @ui5/project/build/cache/index/ResourceIndex + * @description Manages an indexed view of build resources with hash-based tracking. + * + * ResourceIndex provides efficient resource tracking through hash tree structures, + * enabling fast delta detection and signature calculation for build caching. + */ +import HashTree from "./HashTree.js"; +import {createResourceIndex} from "../utils.js"; + +/** + * Manages an indexed view of build resources with content-based hashing. + * + * ResourceIndex wraps a HashTree to provide resource indexing capabilities for build caching. + * It maintains resource metadata (path, integrity, size, modification time) and computes + * signatures for change detection. The index supports efficient updates and can be + * persisted/restored from cache. + * + * @example + * // Create from resources + * const index = await ResourceIndex.create(resources, registry); + * const signature = index.getSignature(); + * + * @example + * // Update with delta detection + * const {changedPaths, resourceIndex} = await ResourceIndex.fromCacheWithDelta( + * cachedIndex, + * currentResources + * ); + */ +export default class ResourceIndex { + #tree; + #indexTimestamp; + + /** + * Creates a new ResourceIndex instance. + * + * @param {HashTree} tree - The hash tree containing resource metadata + * @param {number} [indexTimestamp] - Timestamp when the index was created (defaults to current time) + * @private + */ + constructor(tree, indexTimestamp) { + this.#tree = tree; + this.#indexTimestamp = indexTimestamp || Date.now(); + } + + /** + * Creates a new ResourceIndex from a set of resources. + * + * Builds a hash tree from the provided resources, computing content hashes + * and metadata for each resource. The resulting index can be used for + * signature calculation and change tracking. + * + * @param {Array<@ui5/fs/Resource>} resources - Resources to index + * @param {import("./TreeRegistry.js").default} [registry] - Optional tree registry for deduplication + * @returns {Promise} A new resource index + * @public + */ + static async create(resources, registry) { + const resourceIndex = await createResourceIndex(resources); + const tree = new HashTree(resourceIndex, {registry}); + return new ResourceIndex(tree); + } + + /** + * Restores a ResourceIndex from cache and applies delta updates. + * + * Takes a cached index and a current set of resources, then: + * 1. Identifies removed resources (in cache but not in current set) + * 2. Identifies added/updated resources (new or modified since cache) + * 3. Returns both the updated index and list of all changed paths + * + * This method is optimized for incremental builds where most resources + * remain unchanged between builds. + * + * @param {object} indexCache - Cached index object from previous build + * @param {number} indexCache.indexTimestamp - Timestamp of cached index + * @param {object} indexCache.indexTree - Cached hash tree structure + * @param {Array<@ui5/fs/Resource>} resources - Current resources to compare against cache + * @returns {Promise<{changedPaths: string[], resourceIndex: ResourceIndex}>} + * Object containing array of all changed resource paths and the updated index + * @public + */ + static async fromCacheWithDelta(indexCache, resources) { + const {indexTimestamp, indexTree} = indexCache; + const tree = HashTree.fromCache(indexTree, {indexTimestamp}); + const currentResourcePaths = new Set(resources.map((resource) => resource.getOriginalPath())); + const removed = tree.getResourcePaths().filter((resourcePath) => { + return !currentResourcePaths.has(resourcePath); + }); + await tree.removeResources(removed); + const {added, updated} = await tree.upsertResources(resources); + return { + changedPaths: [...added, ...updated, ...removed], + resourceIndex: new ResourceIndex(tree), + }; + } + + /** + * Restores a ResourceIndex from cached metadata. + * + * Reconstructs the resource index from cached metadata without performing + * content hash verification. Useful when the cache is known to be valid + * and fast restoration is needed. + * + * @param {object} indexCache - Cached index object + * @param {Object} indexCache.resourceMetadata - + * Map of resource paths to metadata (integrity, lastModified, size) + * @param {import("./TreeRegistry.js").default} [registry] - Optional tree registry for deduplication + * @returns {Promise} Restored resource index + * @public + */ + static async fromCache(indexCache, registry) { + const resourceIndex = Object.entries(indexCache.resourceMetadata).map(([path, metadata]) => { + return { + path, + integrity: metadata.integrity, + lastModified: metadata.lastModified, + size: metadata.size, + }; + }); + const tree = new HashTree(resourceIndex, {registry}); + return new ResourceIndex(tree); + } + + /** + * Creates a deep copy of this ResourceIndex. + * + * The cloned index has its own hash tree but shares the same timestamp + * as the original. Useful for creating independent index variations. + * + * @returns {ResourceIndex} A cloned resource index + * @public + */ + clone() { + const cloned = new ResourceIndex(this.#tree.clone(), this.#indexTimestamp); + return cloned; + } + + /** + * Creates a derived ResourceIndex by adding additional resources. + * + * Derives a new hash tree from the current tree by incorporating + * additional resources. The original index remains unchanged. + * This is useful for creating task-specific resource views. + * + * @param {Array<@ui5/fs/Resource>} additionalResources - Resources to add to the derived index + * @returns {Promise} A new resource index with the additional resources + * @public + */ + async deriveTree(additionalResources) { + const resourceIndex = await createResourceIndex(additionalResources); + return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); + } + + /** + * Updates existing resources in the index. + * + * Updates metadata for resources that already exist in the index. + * Resources not present in the index are ignored. + * + * @param {Array<@ui5/fs/Resource>} resources - Resources to update + * @returns {Promise} Array of paths for resources that were updated + * @public + */ + async updateResources(resources) { + return await this.#tree.updateResources(resources); + } + + /** + * Inserts or updates resources in the index. + * + * For each resource: + * - If it exists in the index and has changed, it's updated + * - If it doesn't exist in the index, it's added + * - If it exists and hasn't changed, no action is taken + * + * @param {Array<@ui5/fs/Resource>} resources - Resources to upsert + * @returns {Promise<{added: string[], updated: string[]}>} + * Object with arrays of added and updated resource paths + * @public + */ + async upsertResources(resources) { + return await this.#tree.upsertResources(resources); + } + + /** + * Computes the signature hash for this resource index. + * + * The signature is the root hash of the underlying hash tree, + * representing the combined state of all indexed resources. + * Any change to any resource will result in a different signature. + * + * @returns {string} SHA-256 hash signature of the resource index + * @public + */ + getSignature() { + return this.#tree.getRootHash(); + } + + /** + * Serializes the ResourceIndex to a cache object. + * + * Converts the index to a plain object suitable for JSON serialization + * and storage in the build cache. The cached object can be restored + * using fromCache() or fromCacheWithDelta(). + * + * @returns {object} Cache object containing timestamp and tree structure + * @returns {number} return.indexTimestamp - Timestamp when index was created + * @returns {object} return.indexTree - Serialized hash tree structure + * @public + */ + toCacheObject() { + return { + indexTimestamp: this.#indexTimestamp, + indexTree: this.#tree.toCacheObject(), + }; + } + + // #getResourceMetadata() { + // const resources = this.#tree.getAllResources(); + // const resourceMetadata = Object.create(null); + // for (const resource of resources) { + // resourceMetadata[resource.path] = { + // lastModified: resource.lastModified, + // size: resource.size, + // integrity: resource.integrity, + // inode: resource.inode, + // }; + // } + // return resourceMetadata; + // } +} diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js new file mode 100644 index 00000000000..92550b9ba52 --- /dev/null +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -0,0 +1,379 @@ +import path from "node:path/posix"; +import {matchResourceMetadataStrict} from "../utils.js"; + +/** + * Registry for coordinating batch updates across multiple Merkle trees that share nodes by reference. + * + * When multiple trees (e.g., derived trees) share directory and resource nodes through structural sharing, + * direct mutations would be visible to all trees simultaneously. The TreeRegistry provides a transaction-like + * mechanism to batch and coordinate updates: + * + * 1. Changes are scheduled via scheduleUpsert() and scheduleRemoval() without immediately modifying trees + * 2. During flush(), all pending operations are applied atomically across all registered trees + * 3. Shared nodes are modified only once, with changes propagating to all trees that reference them + * 4. Directory hashes are recomputed efficiently in a single bottom-up pass + * + * This approach ensures consistency when multiple trees represent filtered views of the same underlying data. + * + * @property {Set} trees - All registered HashTree instances + * @property {Map} pendingUpserts - Resource path to resource mappings for scheduled upserts + * @property {Set} pendingRemovals - Resource paths scheduled for removal + */ +export default class TreeRegistry { + trees = new Set(); + pendingUpserts = new Map(); + pendingRemovals = new Set(); + + /** + * Register a HashTree instance with this registry for coordinated updates. + * + * Once registered, the tree will participate in all batch operations triggered by flush(). + * Multiple trees can share the same underlying nodes through structural sharing. + * + * @param {import('./HashTree.js').default} tree - HashTree instance to register + */ + register(tree) { + this.trees.add(tree); + } + + /** + * Remove a HashTree instance from this registry. + * + * After unregistering, the tree will no longer participate in batch operations. + * Any pending operations scheduled before unregistration will still be applied during flush(). + * + * @param {import('./HashTree.js').default} tree - HashTree instance to unregister + */ + unregister(tree) { + this.trees.delete(tree); + } + + /** + * Schedule a resource update to be applied during flush(). + * + * This method delegates to scheduleUpsert() for backward compatibility. + * Prefer using scheduleUpsert() directly for new code. + * + * @param {@ui5/fs/Resource} resource - Resource instance to update + */ + scheduleUpdate(resource) { + this.scheduleUpsert(resource); + } + + /** + * Schedule a resource upsert (insert or update) to be applied during flush(). + * + * If a resource with the same path doesn't exist, it will be inserted (including creating + * any necessary parent directories). If it exists, its metadata will be updated if changed. + * Scheduling an upsert cancels any pending removal for the same resource path. + * + * @param {@ui5/fs/Resource} resource - Resource instance to upsert + */ + scheduleUpsert(resource) { + const resourcePath = resource.getOriginalPath(); + this.pendingUpserts.set(resourcePath, resource); + // Cancel any pending removal for this path + this.pendingRemovals.delete(resourcePath); + } + + /** + * Schedule a resource removal to be applied during flush(). + * + * The resource will be removed from all registered trees that contain it. + * Scheduling a removal cancels any pending upsert for the same resource path. + * Removals are processed before upserts during flush() to handle replacement scenarios. + * + * @param {string} resourcePath - POSIX-style path to the resource (e.g., "src/main.js") + */ + scheduleRemoval(resourcePath) { + this.pendingRemovals.add(resourcePath); + // Cancel any pending upsert for this path + this.pendingUpserts.delete(resourcePath); + } + + /** + * Apply all pending upserts and removals atomically across all registered trees. + * + * This method processes scheduled operations in three phases: + * + * Phase 1: Process removals + * - Delete resource nodes from all trees that contain them + * - Mark affected ancestor directories for hash recomputation + * + * Phase 2: Process upserts (inserts and updates) + * - Group operations by parent directory for efficiency + * - Create missing parent directories as needed + * - Insert new resources or update existing ones + * - Skip updates for resources with unchanged metadata + * - Track modified nodes to avoid duplicate updates to shared nodes + * + * Phase 3: Recompute directory hashes + * - Sort affected directories by depth (deepest first) + * - Recompute hashes bottom-up to root + * - Each shared node is updated once, visible to all trees + * + * After successful completion, all pending operations are cleared. + * + * @returns {Promise<{added: string[], updated: string[], unchanged: string[], removed: string[]}>} + * Object containing arrays of resource paths categorized by operation result + */ + async flush() { + if (this.pendingUpserts.size === 0 && this.pendingRemovals.size === 0) { + return { + added: [], + updated: [], + unchanged: [], + removed: [] + }; + } + + // Track added, updated, unchanged, and removed resources + const addedResources = []; + const updatedResources = []; + const unchangedResources = []; + const removedResources = []; + + // Track which resource nodes we've already modified to handle shared nodes + const modifiedNodes = new Set(); + + // Track all affected trees and the paths that need recomputation + const affectedTrees = new Map(); // tree -> Set of directory paths needing recomputation + + // 1. Handle removals first + for (const resourcePath of this.pendingRemovals) { + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + const resourceName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join(path.sep); + + for (const tree of this.trees) { + const parentNode = tree._findNode(parentPath); + if (!parentNode || parentNode.type !== "directory") { + continue; + } + + if (parentNode.children.has(resourceName)) { + parentNode.children.delete(resourceName); + + if (!affectedTrees.has(tree)) { + affectedTrees.set(tree, new Set()); + } + + this._markAncestorsAffected(tree, parts.slice(0, -1), affectedTrees); + + if (!removedResources.includes(resourcePath)) { + removedResources.push(resourcePath); + } + } + } + } + + // 2. Handle upserts - group by directory + const upsertsByDir = new Map(); // parentPath -> [{resourceName, resource, fullPath}] + + for (const [resourcePath, resource] of this.pendingUpserts) { + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + const resourceName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join(path.sep); + + if (!upsertsByDir.has(parentPath)) { + upsertsByDir.set(parentPath, []); + } + upsertsByDir.get(parentPath).push({resourceName, resource, fullPath: resourcePath}); + } + + // Apply upserts + for (const [parentPath, upserts] of upsertsByDir) { + for (const tree of this.trees) { + // Ensure parent directory exists + let parentNode = tree._findNode(parentPath); + if (!parentNode) { + parentNode = this._ensureDirectoryPath(tree, parentPath.split(path.sep).filter((p) => p.length > 0)); + } + + if (parentNode.type !== "directory") { + continue; + } + + let dirModified = false; + for (const upsert of upserts) { + let resourceNode = parentNode.children.get(upsert.resourceName); + + if (!resourceNode) { + // INSERT: Create new resource node + const TreeNode = tree.root.constructor; + resourceNode = new TreeNode(upsert.resourceName, "resource", { + integrity: await upsert.resource.getIntegrity(), + lastModified: upsert.resource.getLastModified(), + size: await upsert.resource.getSize(), + inode: upsert.resource.getInode() + }); + parentNode.children.set(upsert.resourceName, resourceNode); + modifiedNodes.add(resourceNode); + dirModified = true; + + if (!addedResources.includes(upsert.fullPath)) { + addedResources.push(upsert.fullPath); + } + } else if (resourceNode.type === "resource") { + // UPDATE: Check if modified + if (!modifiedNodes.has(resourceNode)) { + const currentMetadata = { + integrity: resourceNode.integrity, + lastModified: resourceNode.lastModified, + size: resourceNode.size, + inode: resourceNode.inode + }; + + const isUnchanged = await matchResourceMetadataStrict( + upsert.resource, + currentMetadata, + tree.getIndexTimestamp() + ); + + if (!isUnchanged) { + resourceNode.integrity = await upsert.resource.getIntegrity(); + resourceNode.lastModified = upsert.resource.getLastModified(); + resourceNode.size = await upsert.resource.getSize(); + resourceNode.inode = upsert.resource.getInode(); + modifiedNodes.add(resourceNode); + dirModified = true; + + if (!updatedResources.includes(upsert.fullPath)) { + updatedResources.push(upsert.fullPath); + } + } else { + if (!unchangedResources.includes(upsert.fullPath)) { + unchangedResources.push(upsert.fullPath); + } + } + } else { + dirModified = true; + } + } + } + + if (dirModified) { + // Compute hashes for modified/new resources + for (const upsert of upserts) { + const resourceNode = parentNode.children.get(upsert.resourceName); + if (resourceNode && resourceNode.type === "resource" && modifiedNodes.has(resourceNode)) { + tree._computeHash(resourceNode); + } + } + + if (!affectedTrees.has(tree)) { + affectedTrees.set(tree, new Set()); + } + + tree._computeHash(parentNode); + this._markAncestorsAffected(tree, parentPath.split(path.sep).filter((p) => p.length > 0), affectedTrees); + } + } + } + + // Recompute ancestor hashes for all affected trees + for (const [tree, affectedPaths] of affectedTrees) { + // Sort paths by depth (deepest first) to recompute bottom-up + const sortedPaths = Array.from(affectedPaths).sort((a, b) => { + const depthA = a ? a.split(path.sep).length : 0; + const depthB = b ? b.split(path.sep).length : 0; + if (depthA !== depthB) return depthB - depthA; // deeper first + return a.localeCompare(b); + }); + + for (const dirPath of sortedPaths) { + const node = tree._findNode(dirPath); + if (node && node.type === "directory") { + tree._computeHash(node); + } + } + } + + // Clear all pending operations + this.pendingUpserts.clear(); + this.pendingRemovals.clear(); + + return { + added: addedResources, + updated: updatedResources, + unchanged: unchangedResources, + removed: removedResources + }; + } + + /** + * Mark all ancestor directories in a tree as requiring hash recomputation. + * + * When a resource or directory is modified, all ancestor directories up to the root + * need their hashes recomputed to reflect the change. This method tracks those paths + * in the affectedTrees map for later batch processing. + * + * @param {import('./HashTree.js').default} tree - Tree containing the affected path + * @param {string[]} pathParts - Path components of the modified resource/directory + * @param {Map>} affectedTrees - Map tracking affected paths per tree + * @private + */ + _markAncestorsAffected(tree, pathParts, affectedTrees) { + if (!affectedTrees.has(tree)) { + affectedTrees.set(tree, new Set()); + } + + for (let i = 0; i <= pathParts.length; i++) { + affectedTrees.get(tree).add(pathParts.slice(0, i).join(path.sep)); + } + } + + /** + * Ensure a directory path exists in a tree, creating missing directories as needed. + * + * This method walks down the path from root, creating any missing directory nodes. + * It's used during upsert operations to automatically create parent directories + * when inserting resources into paths that don't yet exist. + * + * @param {import('./HashTree.js').default} tree - Tree to create directory path in + * @param {string[]} pathParts - Path components of the directory to ensure exists + * @returns {object} The directory node at the end of the path + * @private + */ + _ensureDirectoryPath(tree, pathParts) { + let current = tree.root; + const TreeNode = tree.root.constructor; + + for (const part of pathParts) { + if (!current.children.has(part)) { + const dirNode = new TreeNode(part, "directory"); + current.children.set(part, dirNode); + } + current = current.children.get(part); + } + + return current; + } + + /** + * Get the number of HashTree instances currently registered with this registry. + * + * @returns {number} Count of registered trees + */ + getTreeCount() { + return this.trees.size; + } + + /** + * Get the total number of pending operations (upserts + removals) waiting to be applied. + * + * @returns {number} Count of pending upserts and removals combined + */ + getPendingUpdateCount() { + return this.pendingUpserts.size + this.pendingRemovals.size; + } + + /** + * Check if there are any pending operations waiting to be applied. + * + * @returns {boolean} True if there are pending upserts or removals, false otherwise + */ + hasPendingUpdates() { + return this.pendingUpserts.size > 0 || this.pendingRemovals.size > 0; + } +} diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index de32b3f39a7..6da9489eaed 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -7,76 +7,146 @@ */ /** - * Compares two resource instances for equality + * Compares a resource instance with cached resource metadata. * - * @param {object} resourceA - First resource to compare - * @param {object} resourceB - Second resource to compare - * @returns {Promise} True if resources are equal - * @throws {Error} If either resource is undefined + * Optimized for quickly rejecting changed files + * + * @param {object} resource Resource instance to compare + * @param {ResourceMetadata} resourceMetadata Resource metadata to compare against + * @param {number} indexTimestamp Timestamp of the metadata creation + * @returns {Promise} True if resource is found to match the metadata + * @throws {Error} If resource or metadata is undefined */ -export async function areResourcesEqual(resourceA, resourceB) { - if (!resourceA || !resourceB) { - throw new Error("Cannot compare undefined resources"); +export async function matchResourceMetadata(resource, resourceMetadata, indexTimestamp) { + if (!resource || !resourceMetadata) { + throw new Error("Cannot compare undefined resources or metadata"); } - if (resourceA === resourceB) { - return true; + + const currentLastModified = resource.getLastModified(); + if (currentLastModified > indexTimestamp) { + // Resource modified after index was created, no need for further checks + return false; } - if (resourceA.getOriginalPath() !== resourceB.getOriginalPath()) { - throw new Error("Cannot compare resources with different original paths"); + if (currentLastModified !== resourceMetadata.lastModified) { + return false; } - if (resourceA.getLastModified() === resourceB.getLastModified()) { - return true; + if (await resource.getSize() !== resourceMetadata.size) { + return false; } - if (await resourceA.getSize() === await resourceB.getSize()) { - return true; + const incomingInode = resource.getInode(); + if (resourceMetadata.inode !== undefined && incomingInode !== undefined && + incomingInode !== resourceMetadata.inode) { + return false; } - if (await resourceA.getIntegrity() === await resourceB.getIntegrity()) { - return true; + + if (currentLastModified === indexTimestamp) { + // If the source modification time is equal to index creation time, + // it's possible for a race condition to have occurred where the file was modified + // during index creation without changing its size. + // In this case, we need to perform an integrity check to determine if the file has changed. + if (await resource.getIntegrity() !== resourceMetadata.integrity) { + return false; + } } - return false; + return true; } -// /** -// * Compares a resource instance with cached metadata fingerprint -// * -// * @param {object} resourceA - Resource instance to compare -// * @param {ResourceMetadata} resourceBMetadata - Cached metadata to compare against -// * @param {number} indexTimestamp - Timestamp of the index creation -// * @returns {Promise} True if resource matches the fingerprint -// * @throws {Error} If resource or metadata is undefined -// */ -// export async function matchResourceMetadata(resourceA, resourceBMetadata, indexTimestamp) { -// if (!resourceA || !resourceBMetadata) { -// throw new Error("Cannot compare undefined resources"); -// } -// if (resourceA.getLastModified() !== resourceBMetadata.lastModified) { -// return false; -// } -// if (await resourceA.getSize() !== resourceBMetadata.size) { -// return false; -// } -// if (resourceBMetadata.inode && resourceA.getInode() !== resourceBMetadata.inode) { -// return false; -// } -// if (await resourceA.getIntegrity() === resourceBMetadata.integrity) { -// return true; -// } -// return false; -// } +/** + * Determines if a resource has changed compared to cached metadata + * + * Optimized for quickly accepting unchanged files. + * I.e. Resources are assumed to be usually unchanged (same lastModified timestamp) + * + * @param {object} resource - Resource instance with methods: getInode(), getSize(), getLastModified(), getIntegrity() + * @param {ResourceMetadata} cachedMetadata - Cached metadata from the tree + * @param {number} indexTimestamp - Timestamp when the tree state was created + * @returns {Promise} True if resource content is unchanged + * @throws {Error} If resource or metadata is undefined + */ +export async function matchResourceMetadataStrict(resource, cachedMetadata, indexTimestamp) { + if (!resource || !cachedMetadata) { + throw new Error("Cannot compare undefined resources or metadata"); + } + + // Check 1: Inode mismatch would indicate file replacement (comparison only if inodes are provided) + const currentInode = resource.getInode(); + if (cachedMetadata.inode !== undefined && currentInode !== undefined && + currentInode !== cachedMetadata.inode) { + return false; + } + + // Check 2: Modification time unchanged would suggest no update needed + const currentLastModified = resource.getLastModified(); + if (currentLastModified === cachedMetadata.lastModified) { + if (currentLastModified !== indexTimestamp) { + // File has not been modified since last indexing. No update needed + return true; + } // else: Edge case. File modified exactly at index time + // Race condition possible - content may have changed during indexing + // Fall through to integrity check + } + + // Check 3: Size mismatch indicates definite content change + const currentSize = await resource.getSize(); + if (currentSize !== cachedMetadata.size) { + return false; + } + + // Check 4: Compare integrity (expensive) + // lastModified has changed, but the content might be the same. E.g. in case of a metadata-only update + const currentIntegrity = await resource.getIntegrity(); + return currentIntegrity === cachedMetadata.integrity; +} export async function createResourceIndex(resources, includeInode = false) { - const index = Object.create(null); - await Promise.all(resources.map(async (resource) => { + return await Promise.all(resources.map(async (resource) => { const resourceMetadata = { + path: resource.getOriginalPath(), + integrity: await resource.getIntegrity(), lastModified: resource.getLastModified(), size: await resource.getSize(), - integrity: await resource.getIntegrity(), }; if (includeInode) { resourceMetadata.inode = resource.getInode(); } - - index[resource.getOriginalPath()] = resourceMetadata; + return resourceMetadata; })); - return index; +} + +/** + * Returns the first truthy value from an array of promises + * + * This function evaluates all promises in parallel and returns immediately + * when the first truthy value is found. If all promises resolve to falsy + * values, null is returned. + * + * @private + * @param {Promise[]} promises - Array of promises to evaluate + * @returns {Promise<*>} The first truthy resolved value or null if all are falsy + */ +export async function firstTruthy(promises) { + return new Promise((resolve, reject) => { + let completed = 0; + const total = promises.length; + + if (total === 0) { + resolve(null); + return; + } + + promises.forEach((promise) => { + Promise.resolve(promise) + .then((value) => { + if (value) { + resolve(value); + } else { + completed++; + if (completed === total) { + resolve(null); + } + } + }) + .catch(reject); + }); + }); } diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 038f25bc638..bcb3c5f12f6 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -149,7 +149,7 @@ class ProjectBuildContext { /** * Determine whether the project has to be built or is already built - * (typically indicated by the presence of a build manifest) + * (typically indicated by the presence of a build manifest or a valid cache) * * @returns {boolean} True if the project needs to be built */ @@ -158,26 +158,11 @@ class ProjectBuildContext { return false; } - return this._buildCache.needsRebuild(); + return this._buildCache.requiresBuild(); } async runTasks() { await this.getTaskRunner().runTasks(); - const updatedResourcePaths = this._buildCache.collectAndClearModifiedPaths(); - - if (updatedResourcePaths.size === 0) { - return; - } - this._log.verbose( - `Project ${this._project.getName()} updated resources: ${Array.from(updatedResourcePaths).join(", ")}`); - const graph = this._buildContext.getGraph(); - const emptySet = new Set(); - - // Propagate changes to all dependents of the project - for (const {project: dep} of graph.traverseDependents(this._project.getName())) { - const projectBuildContext = this._buildContext.getBuildContext(dep.getName()); - projectBuildContext.getBuildCache().resourceChanged(emptySet, updatedResourcePaths); - } } #getBuildManifest() { @@ -190,11 +175,6 @@ class ProjectBuildContext { // Manifest version 0.1 and 0.2 are always used without further checks for legacy reasons return manifest; } - // if (manifest.buildManifest.manifestVersion === "0.3" && - // manifest.buildManifest.cacheKey === this.getCacheKey()) { - // // Manifest version 0.3 is used with a matching cache key - // return manifest; - // } // Unknown manifest version can't be used return; } diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 860256f3b47..81e83a6d6f8 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -108,8 +108,8 @@ class WatchHandler extends EventEmitter { if (!sourceChanges.has(project) && !dependencyChanges.has(project)) { return; } - const projectSourceChanges = sourceChanges.get(project) ?? new Set(); - const projectDependencyChanges = dependencyChanges.get(project) ?? new Set(); + const projectSourceChanges = Array.from(sourceChanges.get(project) ?? new Set()); + const projectDependencyChanges = Array.from(dependencyChanges.get(project) ?? new Set()); const projectBuildContext = this.#buildContext.getBuildContext(project.getName()); const tasksInvalidated = projectBuildContext.getBuildCache().resourceChanged(projectSourceChanges, projectDependencyChanges); diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index 3044d0f7d68..cea92e5b6e6 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -129,7 +129,6 @@ class ComponentProject extends Project { throw new Error(`Unknown path mapping style ${style}`); } - // reader = this._addWriter(reader, style, writer); return reader; } @@ -191,11 +190,7 @@ class ComponentProject extends Project { } }); - return { - namespaceWriter, - generalWriter, - collection - }; + return collection; } _getReader(excludes) { @@ -210,20 +205,31 @@ class ComponentProject extends Project { return reader; } - _addWriter(style, readers, writer) { - let {namespaceWriter, generalWriter} = writer; - if (!namespaceWriter || !generalWriter) { - // TODO: Too hacky - namespaceWriter = writer; - generalWriter = writer; - } - + _addReadersForWriter(readers, writer, style) { if ((style === "runtime" || style === "dist") && this._isRuntimeNamespaced) { // If the project's type requires a namespace at runtime, the // dist- and runtime-style paths are identical to buildtime-style paths style = "buildtime"; } + let generalWriter; + let namespaceWriter; + if (writer.getMapping) { + const mapping = writer.getMapping(); + generalWriter = mapping[`/`]; + for (const writer of Object.values(mapping)) { + if (writer === generalWriter) { + continue; + } + if (namespaceWriter && writer !== namespaceWriter) { + throw new Error(`Cannot determine unique namespace writer for project ${this.getName()}`); + } + namespaceWriter = writer; + } + } else { + throw new Error(`Cannot determine writers for project ${this.getName()}`); + } + switch (style) { case "buildtime": // Writer already uses buildtime style @@ -253,13 +259,6 @@ class ComponentProject extends Project { default: throw new Error(`Unknown path mapping style ${style}`); } - // return readers; - // readers.push(reader); - - // return resourceFactory.createReaderCollectionPrioritized({ - // name: `Reader/Writer collection for project ${this.getName()}`, - // readers - // }); } /* === Internals === */ diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index d1035ee02e6..239dc81bf0b 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -2,6 +2,9 @@ import Specification from "./Specification.js"; import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; +const INITIAL_STAGE_ID = "initial"; +const RESULT_STAGE_ID = "result"; + /** * Project * @@ -15,12 +18,14 @@ import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resour class Project extends Specification { #stages = []; // Stages in order of creation - #currentStageWorkspace; - #currentStageReaders = new Map(); // Initialize an empty map to store the various reader styles + // State #currentStage; - #currentStageReadIndex = -1; - #currentStageName = ""; - #workspaceVersion = 0; + #currentStageReadIndex; + #currentStageId; + + // Cache + #currentStageWorkspace; + #currentStageReaders; // Map to store the various reader styles constructor(parameters) { super(parameters); @@ -29,6 +34,7 @@ class Project extends Specification { } this._resourceTagCollection = null; + this._initStageMetadata(); } /** @@ -269,15 +275,45 @@ class Project extends Specification { * @param {object} [options] * @param {string} [options.style=buildtime] Path style to access resources. * Can be "buildtime", "dist", "runtime" or "flat" + * @param {boolean} [options.excludeSourceReader] If set to true, the source reader is omitted * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ - getReader({style = "buildtime"} = {}) { + getReader({style = "buildtime", excludeSourceReader} = {}) { let reader = this.#currentStageReaders.get(style); - if (reader) { + if (reader && !excludeSourceReader) { // Use cached reader return reader; } + const readers = []; + if (this.#currentStage) { + // Add current writer as highest priority reader + const currentWriter = this.#currentStage.getWriter(); + if (currentWriter) { + this._addReadersForWriter(readers, currentWriter, style); + } else { + const currentReader = this.#currentStage.getCacheReader(); + if (currentReader) { + readers.push(currentReader); + } + } + } + // Add readers for previous stages and source + readers.push(...this.#getReaders(style, excludeSourceReader)); + + reader = createReaderCollectionPrioritized({ + name: `Reader collection for stage '${this.#currentStageId}' of project ${this.getName()}`, + readers + }); + + if (excludeSourceReader) { + return reader; + } + this.#currentStageReaders.set(style, reader); + return reader; + } + + #getReaders(style = "buildtime", excludeSourceReader) { const readers = []; // Add writers for previous stages as readers @@ -285,22 +321,15 @@ class Project extends Specification { // Collect writers from all relevant stages for (let i = stageReadIdx; i >= 0; i--) { - const stageReader = this.#getReaderForStage(this.#stages[i], style); - if (stageReader) { - readers.push(stageReader); - } + this.#addReaderForStage(this.#stages[i], readers, style); } - // Always add source reader + if (excludeSourceReader) { + return readers; + } + // Finally add the project's source reader readers.push(this._getStyledReader(style)); - - reader = createReaderCollectionPrioritized({ - name: `Reader collection for stage '${this.#currentStageName}' of project ${this.getName()}`, - readers: readers - }); - - this.#currentStageReaders.set(style, reader); - return reader; + return readers; } getSourceReader(style = "buildtime") { @@ -318,7 +347,7 @@ class Project extends Specification { * @returns {@ui5/fs/DuplexCollection} DuplexCollection */ getWorkspace() { - if (!this.#currentStage) { + if (this.#currentStage.getId() === RESULT_STAGE_ID) { throw new Error( `Workspace of project ${this.getName()} is currently not available. ` + `This might indicate that the project has already finished building ` + @@ -328,41 +357,19 @@ class Project extends Specification { if (this.#currentStageWorkspace) { return this.#currentStageWorkspace; } + const reader = createReaderCollectionPrioritized({ + name: `Reader collection for stage '${this.#currentStageId}' of project ${this.getName()}`, + readers: this.#getReaders(), + }); const writer = this.#currentStage.getWriter(); const workspace = createWorkspace({ - reader: this.getReader(), - writer: writer.collection || writer + reader, + writer }); this.#currentStageWorkspace = workspace; return workspace; } - useStage(stageId) { - // if (newWriter && this.#writers.has(stageId)) { - // this.#writers.delete(stageId); - // } - if (stageId === this.#currentStage?.getId()) { - // Already using requested stage - return; - } - - const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); - - if (stageIdx === -1) { - throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); - } - - const stage = this.#stages[stageIdx]; - stage.newVersion(this._createWriter()); - this.#currentStage = stage; - this.#currentStageName = stageId; - this.#currentStageReadIndex = stageIdx - 1; // Read from all previous stages - - // Unset "current" reader/writer. They will be recreated on demand - this.#currentStageReaders = new Map(); - this.#currentStageWorkspace = null; - } - /** * Seal the workspace of the project, preventing further modifications. * This is typically called once the project has finished building. Resources from all stages will be used. @@ -370,10 +377,9 @@ class Project extends Specification { * A project can be unsealed by calling useStage() again. * */ - sealWorkspace() { - this.#workspaceVersion++; - this.#currentStage = null; // Unset stage - This blocks further getWorkspace() calls - this.#currentStageName = ``; + useResultStage() { + this.#currentStage = this.#stages.find((s) => s.getId() === RESULT_STAGE_ID); + this.#currentStageId = RESULT_STAGE_ID; this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages // Unset "current" reader/writer. They will be recreated on demand @@ -381,52 +387,105 @@ class Project extends Specification { this.#currentStageWorkspace = null; } - _resetStages() { + _initStageMetadata() { this.#stages = []; - this.#currentStage = null; - this.#currentStageName = ""; + // Initialize with an empty stage for use without stages (i.e. without build cache) + this.#currentStage = new Stage(INITIAL_STAGE_ID, this._createWriter()); + this.#currentStageId = INITIAL_STAGE_ID; this.#currentStageReadIndex = -1; this.#currentStageReaders = new Map(); this.#currentStageWorkspace = null; - this.#workspaceVersion = 0; } - #getReaderForStage(stage, style = "buildtime", includeCache = true) { - const writers = stage.getAllWriters(includeCache); - const readers = []; - for (const writer of writers) { - // Apply project specific handling for using writers as readers, depending on the requested style - this._addWriter(style, readers, writer); + #addReaderForStage(stage, readers, style = "buildtime") { + const writer = stage.getWriter(); + if (writer) { + this._addReadersForWriter(readers, writer, style); + } else { + const reader = stage.getCacheReader(); + if (reader) { + readers.push(reader); + } } - - return createReaderCollectionPrioritized({ - name: `Reader collection for stage '${stage.getId()}' of project ${this.getName()}`, - readers - }); } - getStagesForCache() { - return this.#stages.map((stage) => { - const reader = this.#getReaderForStage(stage, "buildtime", false); - return { - stageId: stage.getId(), - reader - }; - }); - } - - setStages(stageIds, cacheReaders) { - this._resetStages(); // Reset current stages and metadata + initStages(stageIds) { + this._initStageMetadata(); for (let i = 0; i < stageIds.length; i++) { const stageId = stageIds[i]; - const newStage = new Stage(stageId, cacheReaders?.[i]); + const newStage = new Stage(stageId, this._createWriter()); this.#stages.push(newStage); } } + getStage() { + return this.#currentStage; + } + + useStage(stageId) { + if (stageId === this.#currentStage?.getId()) { + // Already using requested stage + return; + } + + const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); + + if (stageIdx === -1) { + throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); + } + + const stage = this.#stages[stageIdx]; + this.#currentStage = stage; + this.#currentStageId = stageId; + this.#currentStageReadIndex = stageIdx - 1; // Read from all previous stages + + // Unset "current" reader/writer caches. They will be recreated on demand + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + } + + setStage(stageId, stageOrCacheReader) { + const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); + if (stageIdx === -1) { + throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); + } + if (!stageOrCacheReader) { + throw new Error( + `Invalid stage or cache reader provided for stage '${stageId}' in project ${this.getName()}`); + } + const oldStage = this.#stages[stageIdx]; + if (oldStage.getId() !== stageId) { + throw new Error( + `Stage ID mismatch for stage '${stageId}' in project ${this.getName()}`); + } + let newStage; + if (stageOrCacheReader instanceof Stage) { + newStage = stageOrCacheReader; + if (oldStage === newStage) { + // No change + return; + } + } else { + newStage = new Stage(stageId, undefined, stageOrCacheReader); + } + this.#stages[stageIdx] = newStage; + if (oldStage === this.#currentStage) { + this.#currentStage = newStage; + // Unset "current" reader/writer. They might be outdated + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + } + } + + setResultStage(reader) { + this._initStageMetadata(); + const resultStage = new Stage(RESULT_STAGE_ID, undefined, reader); + this.#stages.push(resultStage); + } + /* Overwritten in ComponentProject subclass */ - _addWriter(style, readers, writer) { - readers.push(writer); + _addReadersForWriter(readers, writer, style) { + readers.unshift(writer); } getResourceTagCollection() { @@ -454,13 +513,22 @@ class Project extends Specification { async _parseConfiguration(config) {} } +/** + * A stage has either a writer or a reader, never both. + * Consumers need to be able to differentiate between the two + */ class Stage { #id; - #writerVersions = []; // First element is the latest writer + #writer; #cacheReader; - constructor(id, cacheReader) { + constructor(id, writer, cacheReader) { + if (writer && cacheReader) { + throw new Error( + `Stage '${id}' cannot have both a writer and a cache reader`); + } this.#id = id; + this.#writer = writer; this.#cacheReader = cacheReader; } @@ -468,19 +536,12 @@ class Stage { return this.#id; } - newVersion(writer) { - this.#writerVersions.unshift(writer); - } - getWriter() { - return this.#writerVersions[0]; + return this.#writer; } - getAllWriters(includeCache = true) { - if (includeCache && this.#cacheReader) { - return [...this.#writerVersions, this.#cacheReader]; - } - return this.#writerVersions; + getCacheReader() { + return this.#cacheReader; } } diff --git a/packages/project/test/lib/build/cache/BuildTaskCache.js b/packages/project/test/lib/build/cache/BuildTaskCache.js new file mode 100644 index 00000000000..d8efcfdaf82 --- /dev/null +++ b/packages/project/test/lib/build/cache/BuildTaskCache.js @@ -0,0 +1,644 @@ +import test from "ava"; +import sinon from "sinon"; +import BuildTaskCache from "../../../../lib/build/cache/BuildTaskCache.js"; + +// Helper to create mock Resource instances +function createMockResource(path, integrity = "test-hash", lastModified = 1000, size = 100, inode = 1) { + return { + getOriginalPath: () => path, + getPath: () => path, + getIntegrity: async () => integrity, + getLastModified: () => lastModified, + getSize: async () => size, + getInode: () => inode, + getBuffer: async () => Buffer.from("test content"), + getStream: () => null + }; +} + +// Helper to create mock Reader (project or dependency) +function createMockReader(resources = new Map()) { + return { + byPath: sinon.stub().callsFake(async (path) => { + return resources.get(path) || null; + }), + byGlob: sinon.stub().callsFake(async (patterns) => { + // Simple mock: return all resources that match the pattern + const allPaths = Array.from(resources.keys()); + const results = []; + for (const path of allPaths) { + // Very simplified matching - just check if pattern is substring + const patternArray = Array.isArray(patterns) ? patterns : [patterns]; + for (const pattern of patternArray) { + if (pattern === "/**/*" || path.includes(pattern.replace(/\*/g, ""))) { + results.push(resources.get(path)); + break; + } + } + } + return results; + }) + }; +} + +test.afterEach.always(() => { + sinon.restore(); +}); + +// ===== CONSTRUCTOR TESTS ===== + +test("Create BuildTaskCache without metadata", (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + t.truthy(cache, "BuildTaskCache instance created"); + t.is(cache.getTaskName(), "myTask", "Task name is correct"); +}); + +test("Create BuildTaskCache with metadata", (t) => { + const metadata = { + requestSetGraph: { + nodes: [], + nextId: 1 + } + }; + + const cache = new BuildTaskCache("test.project", "myTask", "build-sig", metadata); + + t.truthy(cache, "BuildTaskCache instance created with metadata"); + t.is(cache.getTaskName(), "myTask", "Task name is correct"); +}); + +test("Create BuildTaskCache with complex metadata", (t) => { + const metadata = { + requestSetGraph: { + nodes: [ + { + id: 1, + parent: null, + addedRequests: ["path:/test.js", "patterns:[\"**/*.js\"]"] + } + ], + nextId: 2 + } + }; + + const cache = new BuildTaskCache("test.project", "myTask", "build-sig", metadata); + + t.truthy(cache, "BuildTaskCache created with complex metadata"); +}); + +// ===== METADATA ACCESS TESTS ===== + +test("getTaskName returns correct task name", (t) => { + const cache = new BuildTaskCache("test.project", "mySpecialTask", "build-sig"); + + t.is(cache.getTaskName(), "mySpecialTask", "Returns correct task name"); +}); + +test("getPossibleStageSignatures with no cached signatures", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const signatures = await cache.getPossibleStageSignatures(); + + t.deepEqual(signatures, [], "Returns empty array when no requests recorded"); +}); + +test("getPossibleStageSignatures throws when resourceIndex missing", async (t) => { + const metadata = { + requestSetGraph: { + nodes: [ + { + id: 1, + parent: null, + addedRequests: ["path:/test.js"] + } + ], + nextId: 2 + } + }; + + const cache = new BuildTaskCache("test.project", "myTask", "build-sig", metadata); + + await t.throwsAsync( + async () => { + await cache.getPossibleStageSignatures(); + }, + { + message: /Resource index missing for request set ID/ + }, + "Throws error when resource index is missing" + ); +}); + +// ===== SIGNATURE CALCULATION TESTS ===== + +test("calculateSignature with simple path requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")], + ["/app.js", createMockResource("/app.js", "hash2")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(["/test.js", "/app.js"]), + patterns: new Set() + }; + + const signature = await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.truthy(signature, "Signature generated"); + t.is(typeof signature, "string", "Signature is a string"); +}); + +test("calculateSignature with pattern requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/src/test.js", createMockResource("/src/test.js", "hash1")], + ["/src/app.js", createMockResource("/src/app.js", "hash2")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(), + patterns: new Set(["/**/*.js"]) + }; + + const signature = await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.truthy(signature, "Signature generated for pattern request"); +}); + +test("calculateSignature with dependency requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const projectResources = new Map([ + ["/app.js", createMockResource("/app.js", "hash1")] + ]); + + const depResources = new Map([ + ["/lib/dep.js", createMockResource("/lib/dep.js", "hash-dep")] + ]); + + const projectReader = createMockReader(projectResources); + const dependencyReader = createMockReader(depResources); + + const projectRequests = { + paths: new Set(["/app.js"]), + patterns: new Set() + }; + + const dependencyRequests = { + paths: new Set(["/lib/dep.js"]), + patterns: new Set() + }; + + const signature = await cache.calculateSignature( + projectRequests, + dependencyRequests, + projectReader, + dependencyReader + ); + + t.truthy(signature, "Signature generated with dependency requests"); +}); + +test("calculateSignature returns same signature for same requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; + + const signature1 = await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + const signature2 = await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.is(signature1, signature2, "Same requests produce same signature"); +}); + +test("calculateSignature with empty requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const projectReader = createMockReader(new Map()); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(), + patterns: new Set() + }; + + const signature = await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.truthy(signature, "Signature generated even with no requests"); +}); + +// ===== RESOURCE MATCHING TESTS ===== + +test("matchesChangedResources: exact path match", (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + // Need to populate the cache with some requests first + // We'll use toCacheObject to verify the internal state + const result = cache.matchesChangedResources(["/test.js"], []); + + // Without any recorded requests, should not match + t.false(result, "No match when no requests recorded"); +}); + +test("matchesChangedResources: after recording requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; + + // Record the request + await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + // Now check if it matches + t.true(cache.matchesChangedResources(["/test.js"], []), "Matches exact path"); + t.false(cache.matchesChangedResources(["/other.js"], []), "Doesn't match different path"); +}); + +test("matchesChangedResources: pattern matching", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/src/test.js", createMockResource("/src/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(), + patterns: new Set(["**/*.js"]) + }; + + await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.true(cache.matchesChangedResources(["/src/app.js"], []), "Pattern matches changed .js file"); + t.false(cache.matchesChangedResources(["/src/styles.css"], []), "Pattern doesn't match .css file"); +}); + +test("matchesChangedResources: dependency path match", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const depResources = new Map([ + ["/lib/dep.js", createMockResource("/lib/dep.js", "hash1")] + ]); + + const projectReader = createMockReader(new Map()); + const dependencyReader = createMockReader(depResources); + + const dependencyRequests = { + paths: new Set(["/lib/dep.js"]), + patterns: new Set() + }; + + await cache.calculateSignature( + {paths: new Set(), patterns: new Set()}, + dependencyRequests, + projectReader, + dependencyReader + ); + + t.true(cache.matchesChangedResources([], ["/lib/dep.js"]), "Matches dependency path"); + t.false(cache.matchesChangedResources([], ["/lib/other.js"]), "Doesn't match different dependency"); +}); + +test("matchesChangedResources: dependency pattern match", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const depResources = new Map([ + ["/lib/utils.js", createMockResource("/lib/utils.js", "hash1")] + ]); + + const projectReader = createMockReader(new Map()); + const dependencyReader = createMockReader(depResources); + + const dependencyRequests = { + paths: new Set(), + patterns: new Set(["/lib/**/*.js"]) + }; + + await cache.calculateSignature( + {paths: new Set(), patterns: new Set()}, + dependencyRequests, + projectReader, + dependencyReader + ); + + t.true(cache.matchesChangedResources([], ["/lib/helper.js"]), "Pattern matches changed dependency"); + t.false(cache.matchesChangedResources([], ["/other/file.js"]), "Pattern doesn't match outside path"); +}); + +test("matchesChangedResources: multiple patterns", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/src/app.js", createMockResource("/src/app.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(), + patterns: new Set(["**/*.js", "**/*.css"]) + }; + + await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.true(cache.matchesChangedResources(["/src/app.js"], []), "Matches .js file"); + t.true(cache.matchesChangedResources(["/src/styles.css"], []), "Matches .css file"); + t.false(cache.matchesChangedResources(["/src/image.png"], []), "Doesn't match .png file"); +}); + +// ===== UPDATE INDICES TESTS ===== + +test("updateIndices with no changes", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + // First calculate signature to establish baseline + await cache.calculateSignature( + {paths: new Set(["/test.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + // Update with no changed paths + await cache.updateIndices(new Set(), new Set(), projectReader, dependencyReader); + + t.pass("updateIndices completed with no changes"); +}); + +test("updateIndices with changed resource", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + // First calculate signature + await cache.calculateSignature( + {paths: new Set(["/test.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + // Update the resource + resources.set("/test.js", createMockResource("/test.js", "hash2", 2000)); + + // Update indices + await cache.updateIndices(new Set(["/test.js"]), new Set(), projectReader, dependencyReader); + + t.pass("updateIndices completed with changed resource"); +}); + +test("updateIndices with removed resource", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")], + ["/app.js", createMockResource("/app.js", "hash2")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + // First calculate signature + await cache.calculateSignature( + {paths: new Set(["/test.js", "/app.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + // Remove one resource + resources.delete("/app.js"); + + // Update indices - this is a more complex scenario that involves internal ResourceIndex behavior + // For now, we test that it can be called (deeper testing would require mocking ResourceIndex internals) + try { + await cache.updateIndices(new Set(["/app.js"]), new Set(), projectReader, dependencyReader); + t.pass("updateIndices can be called with removed resource"); + } catch (err) { + // Expected in unit test environment - would work with real ResourceIndex + if (err.message.includes("removeResources is not a function")) { + t.pass("updateIndices attempted to handle removed resource (integration test needed)"); + } else { + throw err; + } + } +}); + +// ===== SERIALIZATION TESTS ===== + +test("toCacheObject returns valid structure", (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const cacheObject = cache.toCacheObject(); + + t.truthy(cacheObject, "Cache object created"); + t.truthy(cacheObject.requestSetGraph, "Contains requestSetGraph"); + t.truthy(cacheObject.requestSetGraph.nodes, "requestSetGraph has nodes"); + t.is(typeof cacheObject.requestSetGraph.nextId, "number", "requestSetGraph has nextId"); +}); + +test("toCacheObject after recording requests", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + await cache.calculateSignature( + {paths: new Set(["/test.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + const cacheObject = cache.toCacheObject(); + + t.truthy(cacheObject.requestSetGraph, "Contains requestSetGraph"); + t.true(cacheObject.requestSetGraph.nodes.length > 0, "Has recorded nodes"); +}); + +test("Round-trip serialization", async (t) => { + const cache1 = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + await cache1.calculateSignature( + {paths: new Set(["/test.js"]), patterns: new Set(["**/*.js"])}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + const cacheObject = cache1.toCacheObject(); + + // Create new cache from serialized data + const cache2 = new BuildTaskCache("test.project", "myTask", "build-sig", cacheObject); + + t.is(cache2.getTaskName(), "myTask", "Task name preserved"); + t.truthy(cache2.toCacheObject(), "Can serialize again"); +}); + +// ===== EDGE CASES ===== + +test("Create cache with special characters in names", (t) => { + const cache = new BuildTaskCache("test.project-123", "my:special:task", "build-sig"); + + t.is(cache.getTaskName(), "my:special:task", "Special characters in task name preserved"); +}); + +test("matchesChangedResources with empty arrays", (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const result = cache.matchesChangedResources([], []); + + t.false(result, "No matches with empty arrays"); +}); + +test("calculateSignature with non-existent resource", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const projectReader = createMockReader(new Map()); // Empty - resource doesn't exist + const dependencyReader = createMockReader(new Map()); + + const projectRequests = { + paths: new Set(["/nonexistent.js"]), + patterns: new Set() + }; + + // Should not throw, just handle gracefully + const signature = await cache.calculateSignature( + projectRequests, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.truthy(signature, "Signature generated even when resource doesn't exist"); +}); + +test("Multiple calculateSignature calls create optimization", async (t) => { + const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + + const resources = new Map([ + ["/test.js", createMockResource("/test.js", "hash1")], + ["/app.js", createMockResource("/app.js", "hash2")] + ]); + + const projectReader = createMockReader(resources); + const dependencyReader = createMockReader(new Map()); + + // First request set + const sig1 = await cache.calculateSignature( + {paths: new Set(["/test.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + // Second request set that includes first + const sig2 = await cache.calculateSignature( + {paths: new Set(["/test.js", "/app.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}, + projectReader, + dependencyReader + ); + + t.truthy(sig1, "First signature generated"); + t.truthy(sig2, "Second signature generated"); + t.not(sig1, sig2, "Different request sets produce different signatures"); + + const cacheObject = cache.toCacheObject(); + t.true(cacheObject.requestSetGraph.nodes.length > 1, "Multiple request sets recorded"); +}); diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js new file mode 100644 index 00000000000..ccc5989da35 --- /dev/null +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -0,0 +1,573 @@ +import test from "ava"; +import sinon from "sinon"; +import ProjectBuildCache from "../../../../lib/build/cache/ProjectBuildCache.js"; + +// Helper to create mock Project instances +function createMockProject(name = "test.project", id = "test-project-id") { + const stages = new Map(); + let currentStage = "source"; + let resultStageReader = null; + + // Create a reusable reader with both byGlob and byPath + const createReader = () => ({ + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }); + + return { + getName: () => name, + getId: () => id, + getSourceReader: sinon.stub().callsFake(() => createReader()), + getReader: sinon.stub().callsFake(() => createReader()), + getStage: sinon.stub().returns({ + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([]) + }) + }), + useStage: sinon.stub().callsFake((stageName) => { + currentStage = stageName; + }), + setStage: sinon.stub().callsFake((stageName, stage) => { + stages.set(stageName, stage); + }), + initStages: sinon.stub(), + setResultStage: sinon.stub().callsFake((reader) => { + resultStageReader = reader; + }), + useResultStage: sinon.stub().callsFake(() => { + currentStage = "result"; + }), + _getCurrentStage: () => currentStage, + _getResultStageReader: () => resultStageReader + }; +} + +// Helper to create mock CacheManager instances +function createMockCacheManager() { + return { + readIndexCache: sinon.stub().resolves(null), + writeIndexCache: sinon.stub().resolves(), + readStageCache: sinon.stub().resolves(null), + writeStageCache: sinon.stub().resolves(), + readBuildManifest: sinon.stub().resolves(null), + writeBuildManifest: sinon.stub().resolves(), + getResourcePathForStage: sinon.stub().resolves(null), + writeStageResource: sinon.stub().resolves() + }; +} + +// Helper to create mock Resource instances +function createMockResource(path, integrity = "test-hash", lastModified = 1000, size = 100, inode = 1) { + return { + getOriginalPath: () => path, + getPath: () => path, + getIntegrity: async () => integrity, + getLastModified: () => lastModified, + getSize: async () => size, + getInode: () => inode, + getBuffer: async () => Buffer.from("test content"), + getStream: () => null + }; +} + +test.afterEach.always(() => { + sinon.restore(); +}); + +// ===== CREATION AND INITIALIZATION TESTS ===== + +test("Create ProjectBuildCache instance", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-signature"; + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + + t.truthy(cache, "ProjectBuildCache instance created"); + t.true(cacheManager.readIndexCache.called, "Index cache was attempted to be loaded"); + t.true(cacheManager.readBuildManifest.called, "Build manifest was attempted to be loaded"); +}); + +test("Create with existing index cache", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-signature"; + + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resource]) + })); + + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "expected-hash", + children: {} + } + }, + taskMetadata: { + "task1": { + requestSetGraph: { + nodes: [], + nextId: 1 + } + } + } + }; + + cacheManager.readIndexCache.resolves(indexCache); + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + + t.truthy(cache, "Cache created with existing index"); + t.true(cache.hasTaskCache("task1"), "Task cache loaded from index"); +}); + +test("Initialize without any cache", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-signature"; + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + + t.true(cache.requiresBuild(), "Build is required when no cache exists"); + t.false(cache.hasAnyCache(), "No task cache exists initially"); +}); + +test("requiresBuild returns true when invalidated tasks exist", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-signature"; + + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.returns({ + byGlob: sinon.stub().resolves([resource]) + }); + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + + // Simulate having a task cache but with changed resources + cache.resourceChanged(["/test.js"], []); + + t.true(cache.requiresBuild(), "Build required when tasks invalidated"); +}); + +// ===== TASK CACHE TESTS ===== + +test("hasTaskCache returns false for non-existent task", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + t.false(cache.hasTaskCache("nonexistent"), "Task cache doesn't exist"); +}); + +test("getTaskCache returns undefined for non-existent task", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + t.is(cache.getTaskCache("nonexistent"), undefined, "Returns undefined"); +}); + +test("isTaskCacheValid returns false for non-existent task", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + t.false(cache.isTaskCacheValid("nonexistent"), "Non-existent task is not valid"); +}); + +test("setTasks initializes project stages", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["task1", "task2", "task3"]); + + t.true(project.initStages.calledOnce, "initStages called once"); + t.deepEqual( + project.initStages.firstCall.args[0], + ["task/task1", "task/task2", "task/task3"], + "Stage names generated correctly" + ); +}); + +test("setDependencyReader sets the dependency reader", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const mockDependencyReader = {byGlob: sinon.stub()}; + cache.setDependencyReader(mockDependencyReader); + + // The reader is stored internally, we can verify by checking it's used later + t.pass("Dependency reader set"); +}); + +test("allTasksCompleted switches to result stage", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + cache.allTasksCompleted(); + + t.true(project.useResultStage.calledOnce, "useResultStage called"); +}); + +// ===== TASK EXECUTION TESTS ===== + +test("prepareTaskExecution: task needs execution when no cache exists", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["myTask"]); + const needsExecution = await cache.prepareTaskExecution("myTask", false); + + t.true(needsExecution, "Task needs execution without cache"); + t.true(project.useStage.calledWith("task/myTask"), "Project switched to task stage"); +}); + +test("prepareTaskExecution: switches project to correct stage", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["task1", "task2"]); + await cache.prepareTaskExecution("task2", false); + + t.true(project.useStage.calledWith("task/task2"), "Switched to task2 stage"); +}); + +test("recordTaskResult: creates task cache if not exists", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["newTask"]); + await cache.prepareTaskExecution("newTask", false); + + const writtenPaths = new Set(["/output.js"]); + const projectRequests = {paths: new Set(["/input.js"]), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + + await cache.recordTaskResult("newTask", writtenPaths, projectRequests, dependencyRequests); + + t.true(cache.hasTaskCache("newTask"), "Task cache created"); + t.true(cache.isTaskCacheValid("newTask"), "Task cache is valid"); +}); + +test("recordTaskResult: removes task from invalidated list", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["task1"]); + await cache.prepareTaskExecution("task1", false); + + // Record initial result + await cache.recordTaskResult("task1", new Set(), {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); + + // Invalidate task + cache.resourceChanged(["/test.js"], []); + + // Re-execute and record + await cache.prepareTaskExecution("task1", false); + await cache.recordTaskResult("task1", new Set(), {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); + + t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks after re-execution"); +}); + +// ===== RESOURCE CHANGE TESTS ===== + +test("resourceChanged: invalidates no tasks when no cache exists", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const taskInvalidated = cache.resourceChanged(["/test.js"], []); + + t.false(taskInvalidated, "No tasks invalidated when no cache exists"); + t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks"); +}); + +test("getChangedProjectResourcePaths: returns empty set for non-invalidated task", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const changedPaths = cache.getChangedProjectResourcePaths("task1"); + + t.deepEqual(changedPaths, new Set(), "Returns empty set"); +}); + +test("getChangedDependencyResourcePaths: returns empty set for non-invalidated task", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const changedPaths = cache.getChangedDependencyResourcePaths("task1"); + + t.deepEqual(changedPaths, new Set(), "Returns empty set"); +}); + +test("resourceChanged: tracks changed resource paths", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + // Create a task cache first + await cache.setTasks(["task1"]); + await cache.prepareTaskExecution("task1", false); + await cache.recordTaskResult("task1", new Set(), + {paths: new Set(["/test.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}); + + // Now invalidate with changed resources + cache.resourceChanged(["/test.js", "/another.js"], ["/dep.js"]); + + const changedProject = cache.getChangedProjectResourcePaths("task1"); + const changedDeps = cache.getChangedDependencyResourcePaths("task1"); + + t.true(changedProject.has("/test.js"), "Project resource tracked"); + t.true(changedProject.has("/another.js"), "Another project resource tracked"); + t.true(changedDeps.has("/dep.js"), "Dependency resource tracked"); +}); + +test("resourceChanged: accumulates multiple invalidations", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + // Create a task cache first + await cache.setTasks(["task1"]); + await cache.prepareTaskExecution("task1", false); + await cache.recordTaskResult("task1", new Set(), + {paths: new Set(["/test.js", "/another.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}); + + // First invalidation + cache.resourceChanged(["/test.js"], []); + + // Second invalidation + cache.resourceChanged(["/another.js"], []); + + const changedProject = cache.getChangedProjectResourcePaths("task1"); + + t.true(changedProject.has("/test.js"), "First change tracked"); + t.true(changedProject.has("/another.js"), "Second change tracked"); + t.is(changedProject.size, 2, "Both changes accumulated"); +}); + +// ===== INVALIDATION TESTS ===== + +test("getInvalidatedTaskNames: returns empty array when no tasks invalidated", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks"); +}); + +test("isTaskCacheValid: returns false for invalidated task", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + // Create a task cache + await cache.setTasks(["task1"]); + await cache.prepareTaskExecution("task1", false); + await cache.recordTaskResult("task1", new Set(), + {paths: new Set(["/test.js"]), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}); + + t.true(cache.isTaskCacheValid("task1"), "Task is valid initially"); + + // Invalidate it + cache.resourceChanged(["/test.js"], []); + + t.false(cache.isTaskCacheValid("task1"), "Task is no longer valid after invalidation"); + t.deepEqual(cache.getInvalidatedTaskNames(), ["task1"], "Task appears in invalidated list"); +}); + +// ===== CACHE STORAGE TESTS ===== + +test("storeCache: writes index cache and build manifest", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const buildManifest = { + manifestVersion: "1.0", + signature: "sig" + }; + + project.getReader.returns({ + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }); + + await cache.storeCache(buildManifest); + + t.true(cacheManager.writeBuildManifest.called, "Build manifest written"); + t.true(cacheManager.writeIndexCache.called, "Index cache written"); +}); + +test("storeCache: writes build manifest only once", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const buildManifest = { + manifestVersion: "1.0", + signature: "sig" + }; + + project.getReader.returns({ + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }); + + await cache.storeCache(buildManifest); + await cache.storeCache(buildManifest); + + t.is(cacheManager.writeBuildManifest.callCount, 1, "Build manifest written only once"); +}); + +// ===== BUILD MANIFEST TESTS ===== + +test("Load build manifest with correct version", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + cacheManager.readBuildManifest.resolves({ + buildManifest: { + manifestVersion: "1.0", + signature: "test-sig" + } + }); + + const cache = await ProjectBuildCache.create(project, "test-sig", cacheManager); + + t.truthy(cache, "Cache created successfully"); +}); + +test("Ignore build manifest with incompatible version", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + cacheManager.readBuildManifest.resolves({ + buildManifest: { + manifestVersion: "2.0", + signature: "test-sig" + } + }); + + const cache = await ProjectBuildCache.create(project, "test-sig", cacheManager); + + t.truthy(cache, "Cache created despite incompatible manifest"); + t.true(cache.requiresBuild(), "Build required when manifest incompatible"); +}); + +test("Throw error on build signature mismatch", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + cacheManager.readBuildManifest.resolves({ + buildManifest: { + manifestVersion: "1.0", + signature: "wrong-signature" + } + }); + + await t.throwsAsync( + async () => { + await ProjectBuildCache.create(project, "test-sig", cacheManager); + }, + { + message: /Build manifest signature wrong-signature does not match expected build signature test-sig/ + }, + "Throws error on signature mismatch" + ); +}); + +// ===== HELPER FUNCTION TESTS ===== + +test("firstTruthy: returns first truthy value from promises", async (t) => { + const {default: ProjectBuildCacheModule} = await import("../../../../lib/build/cache/ProjectBuildCache.js"); + + // Access the firstTruthy function through dynamic evaluation + // Since it's not exported, we test it indirectly through the module's behavior + // This test verifies the behavior exists without direct access + t.pass("firstTruthy is used internally for cache lookups"); +}); + +// ===== EDGE CASES ===== + +test("Create cache with empty project name", async (t) => { + const project = createMockProject("", "empty-project"); + const cacheManager = createMockCacheManager(); + + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + t.truthy(cache, "Cache created with empty project name"); +}); + +test("setTasks with empty task list", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks([]); + + t.true(project.initStages.calledWith([]), "initStages called with empty array"); +}); + +test("prepareTaskExecution with requiresDependencies flag", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["task1"]); + const needsExecution = await cache.prepareTaskExecution("task1", true); + + t.true(needsExecution, "Task needs execution"); + // Flag is passed but doesn't affect basic behavior without dependency reader +}); + +test("recordTaskResult with empty written paths", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + await cache.setTasks(["task1"]); + await cache.prepareTaskExecution("task1", false); + + const writtenPaths = new Set(); + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + + await cache.recordTaskResult("task1", writtenPaths, projectRequests, dependencyRequests); + + t.true(cache.hasTaskCache("task1"), "Task cache created even with no written paths"); +}); + +test("hasAnyCache: returns true after recording task result", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + t.false(cache.hasAnyCache(), "No cache initially"); + + await cache.setTasks(["task1"]); + await cache.prepareTaskExecution("task1", false); + await cache.recordTaskResult("task1", new Set(), + {paths: new Set(), patterns: new Set()}, + {paths: new Set(), patterns: new Set()}); + + t.true(cache.hasAnyCache(), "Has cache after recording result"); +}); diff --git a/packages/project/test/lib/build/cache/ResourceRequestGraph.js b/packages/project/test/lib/build/cache/ResourceRequestGraph.js new file mode 100644 index 00000000000..6d99af2f660 --- /dev/null +++ b/packages/project/test/lib/build/cache/ResourceRequestGraph.js @@ -0,0 +1,988 @@ +import test from "ava"; +import ResourceRequestGraph, {Request} from "../../../../lib/build/cache/ResourceRequestGraph.js"; + +// Request Class Tests +test("Request: Create path request", (t) => { + const request = new Request("path", "a.js"); + t.is(request.type, "path"); + t.is(request.value, "a.js"); +}); + +test("Request: Create patterns request", (t) => { + const request = new Request("patterns", ["*.js", "*.css"]); + t.is(request.type, "patterns"); + t.deepEqual(request.value, ["*.js", "*.css"]); +}); + +test("Request: Create dep-path request", (t) => { + const request = new Request("dep-path", "dependency/file.js"); + t.is(request.type, "dep-path"); + t.is(request.value, "dependency/file.js"); +}); + +test("Request: Create dep-patterns request", (t) => { + const request = new Request("dep-patterns", ["dep/*.js"]); + t.is(request.type, "dep-patterns"); + t.deepEqual(request.value, ["dep/*.js"]); +}); + +test("Request: Reject invalid type", (t) => { + const error = t.throws(() => { + new Request("invalid-type", "value"); + }, {instanceOf: Error}); + t.is(error.message, "Invalid request type: invalid-type"); +}); + +test("Request: Reject non-string value for path type", (t) => { + const error = t.throws(() => { + new Request("path", ["array", "value"]); + }, {instanceOf: Error}); + t.is(error.message, "Request type 'path' requires value to be a string"); +}); + +test("Request: Reject non-string value for dep-path type", (t) => { + const error = t.throws(() => { + new Request("dep-path", ["array", "value"]); + }, {instanceOf: Error}); + t.is(error.message, "Request type 'dep-path' requires value to be a string"); +}); + +test("Request: toKey with string value", (t) => { + const request = new Request("path", "a.js"); + t.is(request.toKey(), "path:a.js"); +}); + +test("Request: toKey with array value", (t) => { + const request = new Request("patterns", ["*.js", "*.css"]); + t.is(request.toKey(), "patterns:[\"*.js\",\"*.css\"]"); +}); + +test("Request: fromKey with string value", (t) => { + const request = Request.fromKey("path:a.js"); + t.is(request.type, "path"); + t.is(request.value, "a.js"); +}); + +test("Request: fromKey with array value", (t) => { + const request = Request.fromKey("patterns:[\"*.js\",\"*.css\"]"); + t.is(request.type, "patterns"); + t.deepEqual(request.value, ["*.js", "*.css"]); +}); + +test("Request: equals returns true for identical requests", (t) => { + const req1 = new Request("path", "a.js"); + const req2 = new Request("path", "a.js"); + t.true(req1.equals(req2)); +}); + +test("Request: equals returns false for different types", (t) => { + const req1 = new Request("path", "a.js"); + const req2 = new Request("dep-path", "a.js"); + t.false(req1.equals(req2)); +}); + +test("Request: equals returns false for different values", (t) => { + const req1 = new Request("path", "a.js"); + const req2 = new Request("path", "b.js"); + t.false(req1.equals(req2)); +}); + +test("Request: equals returns true for identical array values", (t) => { + const req1 = new Request("patterns", ["*.js", "*.css"]); + const req2 = new Request("patterns", ["*.js", "*.css"]); + t.true(req1.equals(req2)); +}); + +test("Request: equals returns false for different array lengths", (t) => { + const req1 = new Request("patterns", ["*.js", "*.css"]); + const req2 = new Request("patterns", ["*.js"]); + t.false(req1.equals(req2)); +}); + +test("Request: equals returns false for different array values", (t) => { + const req1 = new Request("patterns", ["*.js", "*.css"]); + const req2 = new Request("patterns", ["*.js", "*.html"]); + t.false(req1.equals(req2)); +}); + +// ResourceRequestGraph Tests +test("ResourceRequestGraph: Initialize empty graph", (t) => { + const graph = new ResourceRequestGraph(); + t.is(graph.nodes.size, 0); + t.is(graph.nextId, 1); +}); + +test("ResourceRequestGraph: Add first request set (root node)", (t) => { + const graph = new ResourceRequestGraph(); + const requests = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const nodeId = graph.addRequestSet(requests, {result: "xyz-1"}); + + t.is(nodeId, 1); + t.is(graph.nodes.size, 1); + + const node = graph.getNode(nodeId); + t.is(node.id, 1); + t.is(node.parent, null); + t.is(node.addedRequests.size, 2); + t.deepEqual(node.metadata, {result: "xyz-1"}); +}); + +test("ResourceRequestGraph: Add request set with parent relationship", (t) => { + const graph = new ResourceRequestGraph(); + + // Add first request set + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node1 = graph.addRequestSet(set1, {result: "xyz-1"}); + + // Add second request set (superset of first) + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2, {result: "xyz-2"}); + + // Verify parent relationship + const node2Data = graph.getNode(node2); + t.is(node2Data.parent, node1); + t.is(node2Data.addedRequests.size, 1); + t.true(node2Data.addedRequests.has("path:c.js")); +}); + +test("ResourceRequestGraph: Add request set with no overlap creates parent", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1); + + const set2 = [new Request("path", "x.js")]; + const node2 = graph.addRequestSet(set2); + + const node2Data = graph.getNode(node2); + // Even with no overlap, greedy algorithm will select best parent + t.is(node2Data.parent, node1); + t.is(node2Data.addedRequests.size, 1); + t.true(node2Data.addedRequests.has("path:x.js")); +}); + +test("ResourceRequestGraph: getMaterializedRequests returns full set", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node1 = graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2); + + const node2Data = graph.getNode(node2); + const materialized = node2Data.getMaterializedRequests(graph); + + t.is(materialized.length, 3); + const keys = materialized.map((r) => r.toKey()).sort(); + t.deepEqual(keys, ["path:a.js", "path:b.js", "path:c.js"]); +}); + +test("ResourceRequestGraph: getAddedRequests returns only delta", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2); + + const node2Data = graph.getNode(node2); + const added = node2Data.getAddedRequests(); + + t.is(added.length, 1); + t.is(added[0].toKey(), "path:c.js"); +}); + +test("ResourceRequestGraph: findBestMatch returns node with largest subset", (t) => { + const graph = new ResourceRequestGraph(); + + // Add first request set + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1, {result: "xyz-1"}); + + // Add second request set (superset) + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2, {result: "xyz-2"}); + + // Query that contains set2 + const query = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js"), + new Request("path", "x.js") + ]; + const match = graph.findBestMatch(query); + + // Should return node2 (largest subset: 3 items) + t.is(match, node2); +}); + +test("ResourceRequestGraph: findBestMatch returns null when no subset found", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1); + + // Query with no overlap + const query = [ + new Request("path", "x.js"), + new Request("path", "y.js") + ]; + const match = graph.findBestMatch(query); + + t.is(match, null); +}); + +test("ResourceRequestGraph: findExactMatch returns node with identical set", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node1 = graph.addRequestSet(set1); + + const query = [ + new Request("path", "b.js"), + new Request("path", "a.js") // Different order, but same set + ]; + const match = graph.findExactMatch(query); + + t.is(match, node1); +}); + +test("ResourceRequestGraph: findExactMatch returns null for subset", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + graph.addRequestSet(set1); + + const query = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const match = graph.findExactMatch(query); + + t.is(match, null); +}); + +test("ResourceRequestGraph: findExactMatch returns null for superset", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1); + + const query = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const match = graph.findExactMatch(query); + + t.is(match, null); +}); + +test("ResourceRequestGraph: getMetadata returns stored metadata", (t) => { + const graph = new ResourceRequestGraph(); + const metadata = {result: "xyz", cached: true}; + + const set1 = [new Request("path", "a.js")]; + const nodeId = graph.addRequestSet(set1, metadata); + + const retrieved = graph.getMetadata(nodeId); + t.deepEqual(retrieved, metadata); +}); + +test("ResourceRequestGraph: getMetadata returns null for non-existent node", (t) => { + const graph = new ResourceRequestGraph(); + const retrieved = graph.getMetadata(999); + t.is(retrieved, null); +}); + +test("ResourceRequestGraph: setMetadata updates metadata", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const nodeId = graph.addRequestSet(set1, {original: true}); + + graph.setMetadata(nodeId, {updated: true}); + + const retrieved = graph.getMetadata(nodeId); + t.deepEqual(retrieved, {updated: true}); +}); + +test("ResourceRequestGraph: getAllNodeIds returns all node IDs", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1); + + const set2 = [new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + const ids = graph.getAllNodeIds(); + t.is(ids.length, 2); + t.true(ids.includes(node1)); + t.true(ids.includes(node2)); +}); + +test("ResourceRequestGraph: getAllRequests returns all unique requests", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), // Duplicate + new Request("path", "c.js") + ]; + graph.addRequestSet(set2); + + const allRequests = graph.getAllRequests(); + const keys = allRequests.map((r) => r.toKey()).sort(); + + // Should have 3 unique requests + t.is(keys.length, 3); + t.deepEqual(keys, ["path:a.js", "path:b.js", "path:c.js"]); +}); + +test("ResourceRequestGraph: getStats returns correct statistics", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + graph.addRequestSet(set2); + + const stats = graph.getStats(); + + t.is(stats.nodeCount, 2); + t.is(stats.averageRequestsPerNode, 2.5); // (2 + 3) / 2 + t.is(stats.averageStoredDeltaSize, 1.5); // (2 + 1) / 2 + t.is(stats.maxDepth, 1); // node2 is at depth 1 + t.is(stats.compressionRatio, 0.6); // 3 stored / 5 total +}); + +test("ResourceRequestGraph: toMetadataObject exports graph structure", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node2 = graph.addRequestSet(set2); + + const exported = graph.toMetadataObject(); + + t.is(exported.nodes.length, 2); + t.is(exported.nextId, 3); + + const exportedNode1 = exported.nodes.find((n) => n.id === node1); + t.truthy(exportedNode1); + t.is(exportedNode1.parent, null); + t.deepEqual(exportedNode1.addedRequests, ["path:a.js"]); + + const exportedNode2 = exported.nodes.find((n) => n.id === node2); + t.truthy(exportedNode2); + t.is(exportedNode2.parent, node1); + t.deepEqual(exportedNode2.addedRequests, ["path:b.js"]); +}); + +test("ResourceRequestGraph: fromMetadataObject reconstructs graph", (t) => { + const graph1 = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph1.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node2 = graph1.addRequestSet(set2); + + // Export and reconstruct + const exported = graph1.toMetadataObject(); + const graph2 = ResourceRequestGraph.fromMetadataObject(exported); + + // Verify reconstruction + t.is(graph2.nodes.size, 2); + t.is(graph2.nextId, 3); + + const reconstructedNode1 = graph2.getNode(node1); + t.truthy(reconstructedNode1); + t.is(reconstructedNode1.parent, null); + t.is(reconstructedNode1.addedRequests.size, 1); + + const reconstructedNode2 = graph2.getNode(node2); + t.truthy(reconstructedNode2); + t.is(reconstructedNode2.parent, node1); + t.is(reconstructedNode2.addedRequests.size, 1); + + // Verify materialized sets work correctly + const materialized = reconstructedNode2.getMaterializedRequests(graph2); + t.is(materialized.length, 2); +}); + +test("ResourceRequestGraph: Handles different request types", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("patterns", ["*.js"]), + new Request("dep-path", "dep/file.js"), + new Request("dep-patterns", ["dep/*.js"]) + ]; + const nodeId = graph.addRequestSet(set1); + + const node = graph.getNode(nodeId); + const materialized = node.getMaterializedRequests(graph); + + t.is(materialized.length, 4); + + const types = materialized.map((r) => r.type).sort(); + t.deepEqual(types, ["dep-path", "dep-patterns", "path", "patterns"]); +}); + +test("ResourceRequestGraph: Complex parent hierarchy", (t) => { + const graph = new ResourceRequestGraph(); + + // Level 0: Root with 2 requests + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node1 = graph.addRequestSet(set1); + + // Level 1: Add one request + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2); + + // Level 2: Add another request + const set3 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js"), + new Request("path", "d.js") + ]; + const node3 = graph.addRequestSet(set3); + + // Verify hierarchy + const node3Data = graph.getNode(node3); + t.is(node3Data.parent, node2); + + const node2Data = graph.getNode(node2); + t.is(node2Data.parent, node1); + + const stats = graph.getStats(); + t.is(stats.maxDepth, 2); +}); + +test("ResourceRequestGraph: findBestParent chooses optimal parent", (t) => { + const graph = new ResourceRequestGraph(); + + // Create two potential parents + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node1 = graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "x.js"), + new Request("path", "y.js"), + new Request("path", "z.js") + ]; + const node2 = graph.addRequestSet(set2); + + // New set overlaps more with set2 + const set3 = [ + new Request("path", "x.js"), + new Request("path", "y.js"), + new Request("path", "z.js"), + new Request("path", "w.js") + ]; + const node3 = graph.addRequestSet(set3); + + const node3Data = graph.getNode(node3); + // Should choose node2 as parent (only needs to add 1 request vs 5) + t.is(node3Data.parent, node2); + t.is(node3Data.addedRequests.size, 1); +}); + +test("ResourceRequestGraph: Empty request set", (t) => { + const graph = new ResourceRequestGraph(); + + const nodeId = graph.addRequestSet([]); + const node = graph.getNode(nodeId); + + t.is(node.addedRequests.size, 0); + t.is(node.parent, null); +}); + +test("ResourceRequestGraph: Caching works correctly", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + graph.addRequestSet(set1); + + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2); + + const node2Data = graph.getNode(node2); + + // First call should compute and cache + const materialized1 = node2Data.getMaterializedSet(graph); + t.is(materialized1.size, 3); + + // Second call should use cache (same result) + const materialized2 = node2Data.getMaterializedSet(graph); + t.is(materialized2.size, 3); + t.deepEqual(Array.from(materialized1).sort(), Array.from(materialized2).sort()); +}); + +test("ResourceRequestGraph: Usage example from documentation", (t) => { + // Create graph + const graph = new ResourceRequestGraph(); + + // Add first request set + const set1 = [ + new Request("path", "a.js"), + new Request("path", "b.js") + ]; + const node1 = graph.addRequestSet(set1, {result: "xyz-1"}); + t.is(node1, 1); + + // Add second request set (superset of first) + const set2 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + const node2 = graph.addRequestSet(set2, {result: "xyz-2"}); + t.is(node2, 2); + + // Verify parent relationship + const node2Data = graph.getNode(node2); + t.is(node2Data.parent, node1); + t.deepEqual(Array.from(node2Data.addedRequests), ["path:c.js"]); + + // Find best match for a query + const query = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "x.js") + ]; + const match = graph.findBestMatch(query); + t.is(match, node1); + + // Get metadata + const metadata = graph.getMetadata(match); + t.deepEqual(metadata, {result: "xyz-1"}); + + // Get statistics + const stats = graph.getStats(); + t.is(stats.nodeCount, 2); + t.truthy(stats.averageRequestsPerNode); +}); + +// Traversal Iterator Tests +test("ResourceRequestGraph: traverseByDepth iterates in breadth-first order", (t) => { + const graph = new ResourceRequestGraph(); + + // Create a tree structure: + // 1 (depth 0) + // / \ + // 2 3 (depth 1) + // / + // 4 (depth 2) + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + const set3 = [new Request("path", "a.js"), new Request("path", "c.js")]; + const node3 = graph.addRequestSet(set3); + + const set4 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "d.js") + ]; + const node4 = graph.addRequestSet(set4); + + // Collect traversal results + const traversal = []; + for (const {nodeId, depth} of graph.traverseByDepth()) { + traversal.push({nodeId, depth}); + } + + // Verify: depth 0 node comes first, then depth 1 nodes, then depth 2 + t.is(traversal.length, 4); + t.is(traversal[0].nodeId, node1); + t.is(traversal[0].depth, 0); + + // Nodes 2 and 3 should both be at depth 1 (order may vary) + t.is(traversal[1].depth, 1); + t.is(traversal[2].depth, 1); + t.true([node2, node3].includes(traversal[1].nodeId)); + t.true([node2, node3].includes(traversal[2].nodeId)); + + // Node 4 should be at depth 2 + t.is(traversal[3].nodeId, node4); + t.is(traversal[3].depth, 2); +}); + +test("ResourceRequestGraph: traverseByDepth yields correct node information", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1, {meta: "root"}); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2, {meta: "child"}); + + const results = Array.from(graph.traverseByDepth()); + + t.is(results.length, 2); + + // First node + t.is(results[0].nodeId, node1); + t.truthy(results[0].node); + t.is(results[0].depth, 0); + t.is(results[0].parentId, null); + t.deepEqual(results[0].node.metadata, {meta: "root"}); + + // Second node + t.is(results[1].nodeId, node2); + t.is(results[1].depth, 1); + t.is(results[1].parentId, node1); + t.deepEqual(results[1].node.metadata, {meta: "child"}); +}); + +test("ResourceRequestGraph: traverseByDepth handles empty graph", (t) => { + const graph = new ResourceRequestGraph(); + const results = Array.from(graph.traverseByDepth()); + t.is(results.length, 0); +}); + +test("ResourceRequestGraph: traverseByDepth handles multiple root nodes", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + // Create a disconnected node by manipulating internal structure + // Add it without using addRequestSet to avoid automatic parent assignment + const set2 = [new Request("path", "x.js")]; + const node2 = graph.nextId++; + const requestSetNode = { + id: node2, + parent: null, + addedRequests: new Set(set2.map((r) => r.toKey())), + metadata: null, + _fullSetCache: null, + _cacheValid: false, + getMaterializedSet: function(g) { + return new Set(this.addedRequests); + }, + getMaterializedRequests: function(g) { + return Array.from(this.addedRequests).map((key) => Request.fromKey(key)); + }, + getAddedRequests: function() { + return Array.from(this.addedRequests).map((key) => Request.fromKey(key)); + }, + invalidateCache: function() { + this._cacheValid = false; + this._fullSetCache = null; + } + }; + graph.nodes.set(node2, requestSetNode); + + const results = Array.from(graph.traverseByDepth()); + + // Both roots should be at depth 0 + t.is(results.length, 2); + t.is(results[0].depth, 0); + t.is(results[1].depth, 0); + t.is(results[0].parentId, null); + t.is(results[1].parentId, null); +}); + +test("ResourceRequestGraph: traverseByDepth allows early termination", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + const set3 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "c.js") + ]; + graph.addRequestSet(set3); + + // Stop after finding node2 + let count = 0; + for (const {nodeId} of graph.traverseByDepth()) { + count++; + if (nodeId === node2) { + break; + } + } + + // Should have visited 2 nodes, not all 3 + t.is(count, 2); +}); + +test("ResourceRequestGraph: traverseByDepth allows checking deltas", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + graph.addRequestSet(set2); + + const deltas = []; + for (const {node} of graph.traverseByDepth()) { + const delta = node.getAddedRequests(); + deltas.push(delta.map((r) => r.toKey())); + } + + // First node has 1 request, second node adds 1 request + t.deepEqual(deltas, [["path:a.js"], ["path:b.js"]]); +}); + +test("ResourceRequestGraph: traverseSubtree traverses only specified subtree", (t) => { + const graph = new ResourceRequestGraph(); + + // Create structure: + // 1 + // / \ + // 2 3 + // / + // 4 + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + const set3 = [new Request("path", "a.js"), new Request("path", "c.js")]; + graph.addRequestSet(set3); + + const set4 = [ + new Request("path", "a.js"), + new Request("path", "b.js"), + new Request("path", "d.js") + ]; + const node4 = graph.addRequestSet(set4); + + // Traverse only subtree starting from node2 + const results = Array.from(graph.traverseSubtree(node2)); + + // Should only visit node2 and node4 (not node1 or node3) + t.is(results.length, 2); + t.is(results[0].nodeId, node2); + t.is(results[0].depth, 0); // Relative depth from start + t.is(results[1].nodeId, node4); + t.is(results[1].depth, 1); +}); + +test("ResourceRequestGraph: traverseSubtree with root node traverses entire tree", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + graph.addRequestSet(set2); + + const results = Array.from(graph.traverseSubtree(node1)); + + // Should visit all nodes + t.is(results.length, 2); +}); + +test("ResourceRequestGraph: traverseSubtree handles non-existent node", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const results = Array.from(graph.traverseSubtree(999)); + t.is(results.length, 0); +}); + +test("ResourceRequestGraph: traverseSubtree handles leaf node", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + // Traverse from leaf node + const results = Array.from(graph.traverseSubtree(node2)); + + // Should only visit the leaf node itself + t.is(results.length, 1); + t.is(results[0].nodeId, node2); + t.is(results[0].depth, 0); +}); + +test("ResourceRequestGraph: getChildren returns child node IDs", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + const node1 = graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + const set3 = [new Request("path", "a.js"), new Request("path", "c.js")]; + const node3 = graph.addRequestSet(set3); + + const children = graph.getChildren(node1); + + t.is(children.length, 2); + t.true(children.includes(node2)); + t.true(children.includes(node3)); +}); + +test("ResourceRequestGraph: getChildren returns empty array for leaf nodes", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const set2 = [new Request("path", "a.js"), new Request("path", "b.js")]; + const node2 = graph.addRequestSet(set2); + + const children = graph.getChildren(node2); + t.is(children.length, 0); +}); + +test("ResourceRequestGraph: getChildren returns empty array for non-existent node", (t) => { + const graph = new ResourceRequestGraph(); + + const set1 = [new Request("path", "a.js")]; + graph.addRequestSet(set1); + + const children = graph.getChildren(999); + t.is(children.length, 0); +}); + +test("ResourceRequestGraph: Efficient traversal use case", (t) => { + const graph = new ResourceRequestGraph(); + + // Simulate real usage: build a graph of resource requests + const set1 = [new Request("path", "core.js"), new Request("path", "utils.js")]; + const node1 = graph.addRequestSet(set1, {cached: true}); + + const set2 = [ + new Request("path", "core.js"), + new Request("path", "utils.js"), + new Request("path", "components.js") + ]; + const node2 = graph.addRequestSet(set2, {cached: false}); + + // Traverse and collect information + const visited = []; + for (const {nodeId, node, depth} of graph.traverseByDepth()) { + visited.push({ + nodeId, + depth, + deltaSize: node.addedRequests.size, + cached: node.metadata?.cached + }); + } + + t.is(visited.length, 2); + + // Parent processed first + t.is(visited[0].nodeId, node1); + t.is(visited[0].depth, 0); + t.is(visited[0].deltaSize, 2); + t.true(visited[0].cached); + + // Child processed second with delta + t.is(visited[1].nodeId, node2); + t.is(visited[1].depth, 1); + t.is(visited[1].deltaSize, 1); // Only added "components.js" + t.false(visited[1].cached); +}); diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js new file mode 100644 index 00000000000..75fc57efaa7 --- /dev/null +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -0,0 +1,551 @@ +import test from "ava"; +import sinon from "sinon"; +import HashTree from "../../../../../lib/build/cache/index/HashTree.js"; + +// Helper to create mock Resource instances +function createMockResource(path, integrity, lastModified, size, inode) { + return { + getOriginalPath: () => path, + getIntegrity: async () => integrity, + getLastModified: () => lastModified, + getSize: async () => size, + getInode: () => inode + }; +} + +test.afterEach.always((t) => { + sinon.restore(); +}); + +test("Create HashTree", (t) => { + const mt = new HashTree(); + t.truthy(mt, "HashTree instance created"); +}); + +test("Two instances with same resources produce same root hash", (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1"}, + {path: "file2.js", integrity: "hash2"}, + {path: "dir/file3.js", integrity: "hash3"} + ]; + + const tree1 = new HashTree(resources); + const tree2 = new HashTree(resources); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees with identical resources should have identical root hashes"); +}); + +test("Order of resource insertion doesn't affect root hash", (t) => { + const resources1 = [ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"}, + {path: "c.js", integrity: "hash-c"} + ]; + + const resources2 = [ + {path: "c.js", integrity: "hash-c"}, + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ]; + + const tree1 = new HashTree(resources1); + const tree2 = new HashTree(resources2); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees should produce same hash regardless of insertion order"); +}); + +test("Updating resources in two trees produces same root hash", async (t) => { + const initialResources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, + {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200}, + {path: "dir/file3.js", integrity: "hash3", lastModified: 3000, size: 300} + ]; + + const tree1 = new HashTree(initialResources); + const tree2 = new HashTree(initialResources); + const indexTimestamp = tree1.getIndexTimestamp(); + + // Update same resource in both trees + const resource = createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1); + await tree1.updateResource(resource); + await tree2.updateResource(resource); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees should have same root hash after identical updates"); +}); + +test("Multiple updates in same order produce same root hash", async (t) => { + const initialResources = [ + {path: "a.js", integrity: "hash-a", lastModified: 1000, size: 100}, + {path: "b.js", integrity: "hash-b", lastModified: 2000, size: 200}, + {path: "c.js", integrity: "hash-c", lastModified: 3000, size: 300}, + {path: "dir/d.js", integrity: "hash-d", lastModified: 4000, size: 400} + ]; + + const tree1 = new HashTree(initialResources); + const tree2 = new HashTree(initialResources); + const indexTimestamp = tree1.getIndexTimestamp(); + + // Update multiple resources in same order + await tree1.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); + await tree1.updateResource(createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)); + await tree1.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + + await tree2.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); + await tree2.updateResource(createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)); + await tree2.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees should have same root hash after same sequence of updates"); +}); + +test("Multiple updates in different order produce same root hash", async (t) => { + const initialResources = [ + {path: "a.js", integrity: "hash-a", lastModified: 1000, size: 100}, + {path: "b.js", integrity: "hash-b", lastModified: 2000, size: 200}, + {path: "c.js", integrity: "hash-c", lastModified: 3000, size: 300} + ]; + + const tree1 = new HashTree(initialResources); + const tree2 = new HashTree(initialResources); + const indexTimestamp = tree1.getIndexTimestamp(); + + // Update in different orders + await tree1.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); + await tree1.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + await tree1.updateResource(createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)); + + await tree2.updateResource(createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)); + await tree2.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); + await tree2.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees should have same root hash regardless of update order"); +}); + +test("Batch updates produce same hash as individual updates", async (t) => { + const initialResources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, + {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200}, + {path: "file3.js", integrity: "hash3", lastModified: 3000, size: 300} + ]; + + const tree1 = new HashTree(initialResources); + const tree2 = new HashTree(initialResources); + const indexTimestamp = tree1.getIndexTimestamp(); + + // Individual updates + await tree1.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); + await tree1.updateResource(createMockResource("file2.js", "new-hash2", indexTimestamp + 1, 201, 2)); + + // Batch update + const resources = [ + createMockResource("file1.js", "new-hash1", 1001, 101, 1), + createMockResource("file2.js", "new-hash2", 2001, 201, 2) + ]; + await tree2.updateResources(resources); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Batch updates should produce same hash as individual updates"); +}); + +test("Updating resource changes root hash", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, + {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200} + ]; + + const tree = new HashTree(resources); + const originalHash = tree.getRootHash(); + const indexTimestamp = tree.getIndexTimestamp(); + + await tree.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); + const newHash = tree.getRootHash(); + + t.not(originalHash, newHash, + "Root hash should change after resource update"); +}); + +test("Updating resource back to original value restores original hash", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, + {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200} + ]; + + const tree = new HashTree(resources); + const originalHash = tree.getRootHash(); + const indexTimestamp = tree.getIndexTimestamp(); + + // Update and then revert + await tree.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); + await tree.updateResource(createMockResource("file1.js", "hash1", 1000, 100, 1)); + + t.is(tree.getRootHash(), originalHash, + "Root hash should be restored when resource is reverted to original value"); +}); + +test("updateResource returns changed resource path", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100} + ]; + + const tree = new HashTree(resources); + const indexTimestamp = tree.getIndexTimestamp(); + const changed = await tree.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); + + t.deepEqual(changed, ["file1.js"], "Should return path of changed resource"); +}); + +test("updateResource returns empty array when integrity unchanged", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100} + ]; + + const tree = new HashTree(resources); + const changed = await tree.updateResource(createMockResource("file1.js", "hash1", 1000, 100, 1)); + + t.deepEqual(changed, [], "Should return empty array when integrity unchanged"); +}); + +test("updateResource does not change hash when integrity unchanged", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100} + ]; + + const tree = new HashTree(resources); + const originalHash = tree.getRootHash(); + await tree.updateResource(createMockResource("file1.js", "hash1", 1000, 100, 1)); + + t.is(tree.getRootHash(), originalHash, "Hash should not change when integrity unchanged"); +}); + +test("updateResources returns changed resource paths", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, + {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200}, + {path: "file3.js", integrity: "hash3", lastModified: 3000, size: 300} + ]; + + const tree = new HashTree(resources); + const indexTimestamp = tree.getIndexTimestamp(); + + const resourceUpdates = [ + createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1), // Changed + createMockResource("file2.js", "hash2", 2000, 200, 2), // unchanged + createMockResource("file3.js", "new-hash3", indexTimestamp + 1, 301, 3) // Changed + ]; + const changed = await tree.updateResources(resourceUpdates); + + t.deepEqual(changed, ["file1.js", "file3.js"], "Should return only changed paths"); +}); + +test("updateResources returns empty array when no changes", async (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, + {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200} + ]; + + const tree = new HashTree(resources); + const resourceUpdates = [ + createMockResource("file1.js", "hash1", 1000, 100, 1), + createMockResource("file2.js", "hash2", 2000, 200, 2) + ]; + const changed = await tree.updateResources(resourceUpdates); + + t.deepEqual(changed, [], "Should return empty array when no changes"); +}); + +test("Different nested structures with same resources produce different hashes", (t) => { + const resources1 = [ + {path: "a/b/file.js", integrity: "hash1"} + ]; + + const resources2 = [ + {path: "a/file.js", integrity: "hash1"} + ]; + + const tree1 = new HashTree(resources1); + const tree2 = new HashTree(resources2); + + t.not(tree1.getRootHash(), tree2.getRootHash(), + "Different directory structures should produce different hashes"); +}); + +test("Updating unrelated resource doesn't affect consistency", async (t) => { + const initialResources = [ + {path: "file1.js", integrity: "hash1"}, + {path: "file2.js", integrity: "hash2"}, + {path: "dir/file3.js", integrity: "hash3"} + ]; + + const tree1 = new HashTree(initialResources); + const tree2 = new HashTree(initialResources); + + // Update different resources + await tree1.updateResource(createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)); + await tree2.updateResource(createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)); + + // Update an unrelated resource in both + await tree1.updateResource(createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)); + await tree2.updateResource(createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees should remain consistent after updating multiple resources"); +}); + +test("getResourcePaths returns all resource paths in sorted order", (t) => { + const resources = [ + {path: "z.js", integrity: "hash-z"}, + {path: "a.js", integrity: "hash-a"}, + {path: "dir/b.js", integrity: "hash-b"}, + {path: "dir/nested/c.js", integrity: "hash-c"} + ]; + + const tree = new HashTree(resources); + const paths = tree.getResourcePaths(); + + t.deepEqual(paths, [ + "/a.js", + "/dir/b.js", + "/dir/nested/c.js", + "/z.js" + ], "Resource paths should be sorted alphabetically"); +}); + +test("getResourcePaths returns empty array for empty tree", (t) => { + const tree = new HashTree(); + const paths = tree.getResourcePaths(); + + t.deepEqual(paths, [], "Empty tree should return empty array"); +}); + +// ============================================================================ +// upsertResources Tests +// ============================================================================ + +test("upsertResources - insert new resources", async (t) => { + const tree = new HashTree([{path: "a.js", integrity: "hash-a"}]); + const originalHash = tree.getRootHash(); + + const result = await tree.upsertResources([ + createMockResource("b.js", "hash-b", Date.now(), 1024, 1), + createMockResource("c.js", "hash-c", Date.now(), 2048, 2) + ]); + + t.deepEqual(result.added, ["b.js", "c.js"], "Should report added resources"); + t.deepEqual(result.updated, [], "Should have no updates"); + t.deepEqual(result.unchanged, [], "Should have no unchanged"); + + t.truthy(tree.hasPath("b.js"), "Tree should have b.js"); + t.truthy(tree.hasPath("c.js"), "Tree should have c.js"); + t.not(tree.getRootHash(), originalHash, "Root hash should change"); +}); + +test("upsertResources - update existing resources", async (t) => { + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a", lastModified: 1000, size: 100}, + {path: "b.js", integrity: "hash-b", lastModified: 2000, size: 200} + ]); + const originalHash = tree.getRootHash(); + const indexTimestamp = tree.getIndexTimestamp(); + + const result = await tree.upsertResources([ + createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1), + createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2) + ]); + + t.deepEqual(result.added, [], "Should have no additions"); + t.deepEqual(result.updated, ["a.js", "b.js"], "Should report updated resources"); + t.deepEqual(result.unchanged, [], "Should have no unchanged"); + + t.is(tree.getResourceByPath("a.js").integrity, "new-hash-a"); + t.is(tree.getResourceByPath("b.js").integrity, "new-hash-b"); + t.not(tree.getRootHash(), originalHash, "Root hash should change"); +}); + +test("upsertResources - mixed insert, update, and unchanged", async (t) => { + const timestamp = Date.now(); + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a", lastModified: timestamp, size: 100, inode: 1} + ]); + const originalHash = tree.getRootHash(); + + const result = await tree.upsertResources([ + createMockResource("a.js", "hash-a", timestamp, 100, 1), // unchanged + createMockResource("b.js", "hash-b", timestamp, 200, 2), // new + createMockResource("c.js", "hash-c", timestamp, 300, 3) // new + ]); + + t.deepEqual(result.unchanged, ["a.js"], "Should report unchanged resource"); + t.deepEqual(result.added, ["b.js", "c.js"], "Should report added resources"); + t.deepEqual(result.updated, [], "Should have no updates"); + + t.not(tree.getRootHash(), originalHash, "Root hash should change (new resources added)"); +}); + +// ============================================================================ +// removeResources Tests +// ============================================================================ + +test("removeResources - remove existing resources", async (t) => { + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"}, + {path: "c.js", integrity: "hash-c"} + ]); + const originalHash = tree.getRootHash(); + + const result = await tree.removeResources(["b.js", "c.js"]); + + t.deepEqual(result.removed, ["b.js", "c.js"], "Should report removed resources"); + t.deepEqual(result.notFound, [], "Should have no not found"); + + t.truthy(tree.hasPath("a.js"), "Tree should still have a.js"); + t.false(tree.hasPath("b.js"), "Tree should not have b.js"); + t.false(tree.hasPath("c.js"), "Tree should not have c.js"); + t.not(tree.getRootHash(), originalHash, "Root hash should change"); +}); + +test("removeResources - remove non-existent resources", async (t) => { + const tree = new HashTree([{path: "a.js", integrity: "hash-a"}]); + const originalHash = tree.getRootHash(); + + const result = await tree.removeResources(["b.js", "c.js"]); + + t.deepEqual(result.removed, [], "Should have no removals"); + t.deepEqual(result.notFound, ["b.js", "c.js"], "Should report not found"); + + t.is(tree.getRootHash(), originalHash, "Root hash should not change"); +}); + +test("removeResources - mixed existing and non-existent", async (t) => { + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ]); + + const result = await tree.removeResources(["b.js", "c.js", "d.js"]); + + t.deepEqual(result.removed, ["b.js"], "Should report removed resources"); + t.deepEqual(result.notFound, ["c.js", "d.js"], "Should report not found"); + + t.truthy(tree.hasPath("a.js"), "Tree should still have a.js"); + t.false(tree.hasPath("b.js"), "Tree should not have b.js"); +}); + +test("removeResources - remove from nested directory", async (t) => { + const tree = new HashTree([ + {path: "dir1/dir2/a.js", integrity: "hash-a"}, + {path: "dir1/dir2/b.js", integrity: "hash-b"}, + {path: "dir1/c.js", integrity: "hash-c"} + ]); + + const result = await tree.removeResources(["dir1/dir2/a.js"]); + + t.deepEqual(result.removed, ["dir1/dir2/a.js"], "Should remove nested resource"); + t.false(tree.hasPath("dir1/dir2/a.js"), "Should not have dir1/dir2/a.js"); + t.truthy(tree.hasPath("dir1/dir2/b.js"), "Should still have dir1/dir2/b.js"); + t.truthy(tree.hasPath("dir1/c.js"), "Should still have dir1/c.js"); +}); + +// ============================================================================ +// Critical Flaw Tests +// ============================================================================ + +test("deriveTree - copies only modified directories (copy-on-write)", (t) => { + const tree1 = new HashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ]); + + // Derive a new tree (should share structure per design goal) + const tree2 = tree1.deriveTree([]); + + // Check if they share the "shared" directory node initially + const dir1Before = tree1.root.children.get("shared"); + const dir2Before = tree2.root.children.get("shared"); + + t.is(dir1Before, dir2Before, "Should share same directory node after deriveTree"); + + // Now insert into tree2 via the intended API (not directly) + tree2._insertResourceWithSharing("shared/c.js", {integrity: "hash-c"}); + + // Check what happened + const dir1After = tree1.root.children.get("shared"); + const dir2After = tree2.root.children.get("shared"); + + // EXPECTED BEHAVIOR (per copy-on-write): + // - Tree2 should copy "shared" directory to add "c.js" without affecting tree1 + // - dir2After !== dir1After (tree2 has its own copy) + // - dir1After === dir1Before (tree1 unchanged) + + t.is(dir1After, dir1Before, "Tree1 should be unaffected"); + t.not(dir2After, dir1After, "Tree2 should have its own copy after modification"); +}); + +test("deriveTree - preserves structural sharing for unmodified paths", (t) => { + const tree1 = new HashTree([ + {path: "shared/nested/deep/a.js", integrity: "hash-a"}, + {path: "other/b.js", integrity: "hash-b"} + ]); + + // Derive tree and add to "other" directory + const tree2 = tree1.deriveTree([]); + tree2._insertResourceWithSharing("other/c.js", {integrity: "hash-c"}); + + // The "shared" directory should still be shared (not copied) + // because we didn't modify it + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + + t.is(sharedDir1, sharedDir2, + "Unmodified 'shared' directory should remain shared between trees"); + + // But "other" should be copied (we modified it) + const otherDir1 = tree1.root.children.get("other"); + const otherDir2 = tree2.root.children.get("other"); + + t.not(otherDir1, otherDir2, + "Modified 'other' directory should be copied in tree2"); + + // Verify tree1 wasn't affected + t.false(tree1.hasPath("other/c.js"), "Tree1 should not have c.js"); + t.true(tree2.hasPath("other/c.js"), "Tree2 should have c.js"); +}); + +test("deriveTree - changes propagate to derived trees (shared view)", async (t) => { + const tree1 = new HashTree([ + {path: "shared/a.js", integrity: "hash-a", lastModified: 1000, size: 100} + ]); + + // Create derived tree - it's a view on the same data, not an independent copy + const tree2 = tree1.deriveTree([ + {path: "unique/b.js", integrity: "hash-b"} + ]); + + // Get reference to shared directory in both trees + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + + // By design: They SHOULD share the same node reference + t.is(sharedDir1, sharedDir2, "Trees share directory nodes (intentional design)"); + + // When tree1 is updated, tree2 sees the change (filtered view behavior) + const indexTimestamp = tree1.getIndexTimestamp(); + await tree1.updateResource( + createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) + ); + + // Both trees see the update as per design + const node1 = tree1.root.children.get("shared").children.get("a.js"); + const node2 = tree2.root.children.get("shared").children.get("a.js"); + + t.is(node1, node2, "Same resource node (shared reference)"); + t.is(node1.integrity, "new-hash-a", "Tree1 sees update"); + t.is(node2.integrity, "new-hash-a", "Tree2 also sees update (intentional)"); + + // This is the intended behavior: derived trees are views, not snapshots + // Tree2 filters which resources it exposes, but underlying data is shared +}); diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js new file mode 100644 index 00000000000..8d9e7d70480 --- /dev/null +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -0,0 +1,567 @@ +import test from "ava"; +import sinon from "sinon"; +import HashTree from "../../../../../lib/build/cache/index/HashTree.js"; +import TreeRegistry from "../../../../../lib/build/cache/index/TreeRegistry.js"; + +// Helper to create mock Resource instances +function createMockResource(path, integrity, lastModified, size, inode) { + return { + getOriginalPath: () => path, + getIntegrity: async () => integrity, + getLastModified: () => lastModified, + getSize: async () => size, + getInode: () => inode + }; +} + +test.afterEach.always((t) => { + sinon.restore(); +}); + +// ============================================================================ +// TreeRegistry Tests +// ============================================================================ + +test("TreeRegistry - register and track trees", (t) => { + const registry = new TreeRegistry(); + new HashTree([{path: "a.js", integrity: "hash1"}], {registry}); + new HashTree([{path: "b.js", integrity: "hash2"}], {registry}); + + t.is(registry.getTreeCount(), 2, "Should track both trees"); +}); + +test("TreeRegistry - schedule and flush updates", async (t) => { + const registry = new TreeRegistry(); + const resources = [{path: "file.js", integrity: "hash1"}]; + const tree = new HashTree(resources, {registry}); + + const originalHash = tree.getRootHash(); + + const resource = createMockResource("file.js", "hash2", Date.now(), 2048, 456); + registry.scheduleUpdate(resource); + t.is(registry.getPendingUpdateCount(), 1, "Should have one pending update"); + + const result = await registry.flush(); + t.is(registry.getPendingUpdateCount(), 0, "Should have no pending updates after flush"); + t.deepEqual(result.updated, ["file.js"], "Should return changed resource path"); + + const newHash = tree.getRootHash(); + t.not(originalHash, newHash, "Root hash should change after flush"); +}); + +test("TreeRegistry - flush returns only changed resources", async (t) => { + const registry = new TreeRegistry(); + const timestamp = Date.now(); + const resources = [ + {path: "file1.js", integrity: "hash1", lastModified: timestamp, size: 1024, inode: 123}, + {path: "file2.js", integrity: "hash2", lastModified: timestamp, size: 2048, inode: 124} + ]; + new HashTree(resources, {registry}); + + registry.scheduleUpdate(createMockResource("file1.js", "new-hash1", timestamp, 1024, 123)); + registry.scheduleUpdate(createMockResource("file2.js", "hash2", timestamp, 2048, 124)); // unchanged + + const result = await registry.flush(); + t.deepEqual(result.updated, ["file1.js"], "Should return only changed resource"); +}); + +test("TreeRegistry - flush returns empty array when no changes", async (t) => { + const registry = new TreeRegistry(); + const timestamp = Date.now(); + const resources = [{path: "file.js", integrity: "hash1", lastModified: timestamp, size: 1024, inode: 123}]; + new HashTree(resources, {registry}); + + registry.scheduleUpdate(createMockResource("file.js", "hash1", timestamp, 1024, 123)); // same value + + const result = await registry.flush(); + t.deepEqual(result.updated, [], "Should return empty array when no actual changes"); +}); + +test("TreeRegistry - batch updates affect all trees sharing nodes", async (t) => { + const registry = new TreeRegistry(); + const resources = [ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ]; + + const tree1 = new HashTree(resources, {registry}); + const originalHash1 = tree1.getRootHash(); + + // Create derived tree that shares "shared" directory + const tree2 = tree1.deriveTree([{path: "unique/c.js", integrity: "hash-c"}]); + const originalHash2 = tree2.getRootHash(); + t.not(originalHash1, originalHash2, "Hashes should differ due to unique content"); + + // Verify they share the same "shared" directory node + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + t.is(sharedDir1, sharedDir2, "Should share the same 'shared' directory node"); + + // Update shared resource + registry.scheduleUpdate(createMockResource("shared/a.js", "new-hash-a", Date.now(), 2048, 999)); + const result = await registry.flush(); + + t.deepEqual(result.updated, ["shared/a.js"], "Should report the updated resource"); + + const newHash1 = tree1.getRootHash(); + const newHash2 = tree2.getRootHash(); + + t.not(originalHash1, newHash1, "Tree1 hash should change"); + t.not(originalHash2, newHash2, "Tree2 hash should change"); + t.not(newHash1, newHash2, "Hashes should differ due to unique content"); + + // Both trees should see the update + const resource1 = tree1.getResourceByPath("shared/a.js"); + const resource2 = tree2.getResourceByPath("shared/a.js"); + + t.is(resource1.integrity, "new-hash-a", "Tree1 should have updated integrity"); + t.is(resource2.integrity, "new-hash-a", "Tree2 should have updated integrity (shared node)"); +}); + +test("TreeRegistry - handles missing resources gracefully during flush", async (t) => { + const registry = new TreeRegistry(); + new HashTree([{path: "exists.js", integrity: "hash1"}], {registry}); + + // Schedule update for non-existent resource + registry.scheduleUpdate(createMockResource("missing.js", "hash2", Date.now(), 1024, 444)); + + // Should not throw + await t.notThrows(async () => await registry.flush(), "Should handle missing resources gracefully"); +}); + +test("TreeRegistry - multiple updates to same resource", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([{path: "file.js", integrity: "v1"}], {registry}); + + const timestamp = Date.now(); + registry.scheduleUpdate(createMockResource("file.js", "v2", timestamp, 1024, 100)); + registry.scheduleUpdate(createMockResource("file.js", "v3", timestamp + 1, 1024, 100)); + registry.scheduleUpdate(createMockResource("file.js", "v4", timestamp + 2, 1024, 100)); + + t.is(registry.getPendingUpdateCount(), 1, "Should consolidate updates to same path"); + + await registry.flush(); + + // Should apply the last update + t.is(tree.getResourceByPath("file.js").integrity, "v4", "Should apply last update"); +}); + +test("TreeRegistry - updates without changes lead to same hash", async (t) => { + const registry = new TreeRegistry(); + const timestamp = Date.now(); + const tree = new HashTree([{ + path: "/src/foo/file1.js", integrity: "v1", + }, { + path: "/src/foo/file3.js", integrity: "v1", + }, { + path: "/src/foo/file2.js", integrity: "v1", + }], {registry}); + const initialHash = tree.getRootHash(); + const file2Hash = tree.getResourceByPath("/src/foo/file2.js").hash; + + registry.scheduleUpdate(createMockResource("/src/foo/file2.js", "v1", timestamp, 1024, 200)); + + t.is(registry.getPendingUpdateCount(), 1, "Should have one pending update"); + + await registry.flush(); + + // Should apply the last update + t.is(tree.getResourceByPath("/src/foo/file2.js").hash.toString("hex"), file2Hash.toString("hex"), + "Should have same has for file"); + t.is(tree.getRootHash(), initialHash, "Root hash should remain unchanged"); +}); + +test("TreeRegistry - unregister tree", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([{path: "a.js", integrity: "hash1"}], {registry}); + const tree2 = new HashTree([{path: "b.js", integrity: "hash2"}], {registry}); + + t.is(registry.getTreeCount(), 2); + + registry.unregister(tree1); + t.is(registry.getTreeCount(), 1); + + // Flush should only affect tree2 + registry.scheduleUpdate(createMockResource("b.js", "new-hash2", Date.now(), 1024, 777)); + await registry.flush(); + + t.notThrows(() => tree2.getRootHash(), "Tree2 should still work"); +}); + +// ============================================================================ +// Derived Tree Tests +// ============================================================================ + +test("deriveTree - creates tree sharing subtrees", (t) => { + const resources = [ + {path: "dir1/a.js", integrity: "hash-a"}, + {path: "dir1/b.js", integrity: "hash-b"} + ]; + + const tree1 = new HashTree(resources); + const tree2 = tree1.deriveTree([{path: "dir2/c.js", integrity: "hash-c"}]); + + // Both trees should have dir1 + t.truthy(tree2.hasPath("dir1/a.js"), "Derived tree should have shared resources"); + t.truthy(tree2.hasPath("dir2/c.js"), "Derived tree should have new resources"); + + // Tree1 should not have dir2 + t.false(tree1.hasPath("dir2/c.js"), "Original tree should not have derived resources"); +}); + +test("deriveTree - shared nodes are the same reference", (t) => { + const resources = [ + {path: "shared/file.js", integrity: "hash1"} + ]; + + const tree1 = new HashTree(resources); + const tree2 = tree1.deriveTree([]); + + // Get the shared directory node from both trees + const dir1 = tree1.root.children.get("shared"); + const dir2 = tree2.root.children.get("shared"); + + t.is(dir1, dir2, "Shared directory nodes should be same reference"); + + // Get the file node + const file1 = dir1.children.get("file.js"); + const file2 = dir2.children.get("file.js"); + + t.is(file1, file2, "Shared resource nodes should be same reference"); +}); + +test("deriveTree - updates to shared nodes visible in all trees", async (t) => { + const registry = new TreeRegistry(); + const resources = [ + {path: "shared/file.js", integrity: "original"} + ]; + + const tree1 = new HashTree(resources, {registry}); + const tree2 = tree1.deriveTree([]); + + // Get nodes before update + const node1Before = tree1.getResourceByPath("shared/file.js"); + const node2Before = tree2.getResourceByPath("shared/file.js"); + + t.is(node1Before, node2Before, "Should be same node reference"); + t.is(node1Before.integrity, "original", "Original integrity"); + + // Update via registry + registry.scheduleUpdate(createMockResource("shared/file.js", "updated", Date.now(), 1024, 555)); + await registry.flush(); + + // Both should see the update (same node) + t.is(node1Before.integrity, "updated", "Tree1 node should be updated"); + t.is(node2Before.integrity, "updated", "Tree2 node should be updated (same reference)"); +}); + +test("deriveTree - multiple levels of derivation", async (t) => { + const registry = new TreeRegistry(); + + const tree1 = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const tree2 = tree1.deriveTree([{path: "b.js", integrity: "hash-b"}]); + const tree3 = tree2.deriveTree([{path: "c.js", integrity: "hash-c"}]); + + t.truthy(tree3.hasPath("a.js"), "Should have resources from tree1"); + t.truthy(tree3.hasPath("b.js"), "Should have resources from tree2"); + t.truthy(tree3.hasPath("c.js"), "Should have its own resources"); + + // Update shared resource + registry.scheduleUpdate(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 111)); + await registry.flush(); + + // All trees should see the update + t.is(tree1.getResourceByPath("a.js").integrity, "new-hash-a"); + t.is(tree2.getResourceByPath("a.js").integrity, "new-hash-a"); + t.is(tree3.getResourceByPath("a.js").integrity, "new-hash-a"); +}); + +test("deriveTree - efficient hash recomputation", async (t) => { + const registry = new TreeRegistry(); + const resources = [ + {path: "dir1/a.js", integrity: "hash-a"}, + {path: "dir1/b.js", integrity: "hash-b"}, + {path: "dir2/c.js", integrity: "hash-c"} + ]; + + const tree1 = new HashTree(resources, {registry}); + const tree2 = tree1.deriveTree([{path: "dir3/d.js", integrity: "hash-d"}]); + + // Spy on _computeHash to count calls + const computeSpy = sinon.spy(tree1, "_computeHash"); + const compute2Spy = sinon.spy(tree2, "_computeHash"); + + // Update resource in shared directory + registry.scheduleUpdate(createMockResource("dir1/a.js", "new-hash-a", Date.now(), 2048, 222)); + await registry.flush(); + + // Each affected directory should be hashed once per tree + // dir1/a.js node, dir1 node, root node for each tree + t.true(computeSpy.callCount >= 3, "Tree1 should recompute affected nodes"); + t.true(compute2Spy.callCount >= 3, "Tree2 should recompute affected nodes"); +}); + +test("deriveTree - independent updates to different directories", async (t) => { + const registry = new TreeRegistry(); + const resources = [ + {path: "dir1/a.js", integrity: "hash-a"} + ]; + + const tree1 = new HashTree(resources, {registry}); + const tree2 = tree1.deriveTree([{path: "dir2/b.js", integrity: "hash-b"}]); + + const hash1Before = tree1.getRootHash(); + const hash2Before = tree2.getRootHash(); + + // Update only in tree2's unique directory + registry.scheduleUpdate(createMockResource("dir2/b.js", "new-hash-b", Date.now(), 1024, 333)); + await registry.flush(); + + const hash1After = tree1.getRootHash(); + const hash2After = tree2.getRootHash(); + + // Both trees are affected because they share the root and dir2 is added/updated via registry + t.not(hash1Before, hash1After, "Tree1 hash changes (dir2 added to shared root)"); + t.not(hash2Before, hash2After, "Tree2 hash should change"); + + // Tree1 now has dir2 because registry ensures directory path exists + t.truthy(tree1.hasPath("dir2/b.js"), "Tree1 should now have dir2/b.js"); + t.truthy(tree2.hasPath("dir2/b.js"), "Tree2 should have dir2/b.js"); +}); + +test("deriveTree - preserves tree statistics correctly", (t) => { + const resources = [ + {path: "dir1/a.js", integrity: "hash-a"}, + {path: "dir1/b.js", integrity: "hash-b"} + ]; + + const tree1 = new HashTree(resources); + const tree2 = tree1.deriveTree([ + {path: "dir2/c.js", integrity: "hash-c"}, + {path: "dir2/d.js", integrity: "hash-d"} + ]); + + const stats1 = tree1.getStats(); + const stats2 = tree2.getStats(); + + t.is(stats1.resources, 2, "Tree1 should have 2 resources"); + t.is(stats2.resources, 4, "Tree2 should have 4 resources"); + t.true(stats2.directories >= stats1.directories, "Tree2 should have at least as many directories"); +}); + +test("deriveTree - empty derivation creates exact copy with shared nodes", (t) => { + const resources = [ + {path: "file.js", integrity: "hash1"} + ]; + + const tree1 = new HashTree(resources); + const tree2 = tree1.deriveTree([]); + + // Should have same structure + t.is(tree1.getRootHash(), tree2.getRootHash(), "Should have same root hash"); + + // But different root nodes (shallow copied) + t.not(tree1.root, tree2.root, "Root nodes should be different"); + + // But shared children + const child1 = tree1.root.children.get("file.js"); + const child2 = tree2.root.children.get("file.js"); + t.is(child1, child2, "Children should be shared"); +}); + +test("deriveTree - complex shared structure", async (t) => { + const registry = new TreeRegistry(); + const resources = [ + {path: "shared/deep/nested/file1.js", integrity: "hash1"}, + {path: "shared/deep/file2.js", integrity: "hash2"}, + {path: "shared/file3.js", integrity: "hash3"} + ]; + + const tree1 = new HashTree(resources, {registry}); + const tree2 = tree1.deriveTree([ + {path: "unique/file4.js", integrity: "hash4"} + ]); + + // Update deeply nested shared file + registry.scheduleUpdate(createMockResource("shared/deep/nested/file1.js", "new-hash1", Date.now(), 2048, 666)); + await registry.flush(); + + // Both trees should reflect the change + t.is(tree1.getResourceByPath("shared/deep/nested/file1.js").integrity, "new-hash1"); + t.is(tree2.getResourceByPath("shared/deep/nested/file1.js").integrity, "new-hash1"); + + // Root hashes should both change + const paths1 = tree1.getResourcePaths(); + const paths2 = tree2.getResourcePaths(); + + t.is(paths1.length, 3, "Tree1 should have 3 resources"); + t.is(paths2.length, 4, "Tree2 should have 4 resources"); +}); + +// ============================================================================ +// upsertResources Tests with Registry +// ============================================================================ + +test("upsertResources - with registry schedules operations", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + + const result = await tree.upsertResources([ + createMockResource("b.js", "hash-b", Date.now(), 1024, 1) + ]); + + t.deepEqual(result.scheduled, ["b.js"], "Should report scheduled paths"); + t.deepEqual(result.added, [], "Should have empty added in scheduled mode"); + t.deepEqual(result.updated, [], "Should have empty updated in scheduled mode"); +}); + +test("upsertResources - with registry and flush", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const originalHash = tree.getRootHash(); + + await tree.upsertResources([ + createMockResource("b.js", "hash-b", Date.now(), 1024, 1), + createMockResource("c.js", "hash-c", Date.now(), 2048, 2) + ]); + + const result = await registry.flush(); + + t.truthy(result.added, "Result should have added array"); + t.true(result.added.includes("b.js"), "Should report b.js as added"); + t.true(result.added.includes("c.js"), "Should report c.js as added"); + + t.truthy(tree.hasPath("b.js"), "Tree should have b.js"); + t.truthy(tree.hasPath("c.js"), "Tree should have c.js"); + t.not(tree.getRootHash(), originalHash, "Root hash should change"); +}); + +test("upsertResources - with derived trees", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([{path: "shared/a.js", integrity: "hash-a"}], {registry}); + const tree2 = tree1.deriveTree([{path: "unique/b.js", integrity: "hash-b"}]); + + await tree1.upsertResources([ + createMockResource("shared/c.js", "hash-c", Date.now(), 1024, 3) + ]); + + await registry.flush(); + + t.truthy(tree1.hasPath("shared/c.js"), "Tree1 should have shared/c.js"); + t.truthy(tree2.hasPath("shared/c.js"), "Tree2 should also have shared/c.js"); + t.false(tree1.hasPath("unique/b.js"), "Tree1 should not have unique/b.js"); + t.truthy(tree2.hasPath("unique/b.js"), "Tree2 should have unique/b.js"); +}); + +// ============================================================================ +// removeResources Tests with Registry +// ============================================================================ + +test("removeResources - with registry schedules operations", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ], {registry}); + + const result = await tree.removeResources(["b.js"]); + + t.deepEqual(result.scheduled, ["b.js"], "Should report scheduled paths"); + t.deepEqual(result.removed, [], "Should have empty removed in scheduled mode"); +}); + +test("removeResources - with registry and flush", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"}, + {path: "c.js", integrity: "hash-c"} + ], {registry}); + const originalHash = tree.getRootHash(); + + await tree.removeResources(["b.js", "c.js"]); + + const result = await registry.flush(); + + t.truthy(result.removed, "Result should have removed array"); + t.true(result.removed.includes("b.js"), "Should report b.js as removed"); + t.true(result.removed.includes("c.js"), "Should report c.js as removed"); + + t.truthy(tree.hasPath("a.js"), "Tree should still have a.js"); + t.false(tree.hasPath("b.js"), "Tree should not have b.js"); + t.false(tree.hasPath("c.js"), "Tree should not have c.js"); + t.not(tree.getRootHash(), originalHash, "Root hash should change"); +}); + +test("removeResources - with derived trees propagates removal", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ], {registry}); + const tree2 = tree1.deriveTree([{path: "unique/c.js", integrity: "hash-c"}]); + + // Verify both trees share the resources + t.truthy(tree1.hasPath("shared/a.js")); + t.truthy(tree1.hasPath("shared/b.js")); + t.truthy(tree2.hasPath("shared/a.js")); + t.truthy(tree2.hasPath("shared/b.js")); + + // Remove from shared directory + await tree1.removeResources(["shared/b.js"]); + await registry.flush(); + + // Both trees should see the removal + t.truthy(tree1.hasPath("shared/a.js"), "Tree1 should still have shared/a.js"); + t.false(tree1.hasPath("shared/b.js"), "Tree1 should not have shared/b.js"); + t.truthy(tree2.hasPath("shared/a.js"), "Tree2 should still have shared/a.js"); + t.false(tree2.hasPath("shared/b.js"), "Tree2 should not have shared/b.js"); + t.truthy(tree2.hasPath("unique/c.js"), "Tree2 should still have unique/c.js"); +}); + +// ============================================================================ +// Combined upsert and remove operations with Registry +// ============================================================================ + +test("upsertResources and removeResources - combined operations", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ], {registry}); + const originalHash = tree.getRootHash(); + + // Schedule both operations + await tree.upsertResources([ + createMockResource("c.js", "hash-c", Date.now(), 1024, 3) + ]); + await tree.removeResources(["b.js"]); + + const result = await registry.flush(); + + t.true(result.added.includes("c.js"), "Should add c.js"); + t.true(result.removed.includes("b.js"), "Should remove b.js"); + + t.truthy(tree.hasPath("a.js"), "Tree should have a.js"); + t.false(tree.hasPath("b.js"), "Tree should not have b.js"); + t.truthy(tree.hasPath("c.js"), "Tree should have c.js"); + t.not(tree.getRootHash(), originalHash, "Root hash should change"); +}); + +test("upsertResources and removeResources - conflicting operations on same path", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + + // Schedule removal then upsert (upsert should win) + await tree.removeResources(["a.js"]); + await tree.upsertResources([ + createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1) + ]); + + const result = await registry.flush(); + + // Upsert cancels removal + t.deepEqual(result.removed, [], "Should have no removals"); + t.true(result.updated.includes("a.js") || result.changed.includes("a.js"), "Should update or keep a.js"); + t.truthy(tree.hasPath("a.js"), "Tree should still have a.js"); +}); From 9c41d853a2149f085d9765be361e35e54e446b94 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 2 Jan 2026 10:45:12 +0100 Subject: [PATCH 047/223] refactor(project): Compress cache using gzip --- .../project/lib/build/cache/CacheManager.js | 74 ++----------------- .../lib/build/cache/ProjectBuildCache.js | 8 +- 2 files changed, 14 insertions(+), 68 deletions(-) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 61630f2e9a5..f2d7c565d90 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -2,6 +2,7 @@ import cacache from "cacache"; import path from "node:path"; import fs from "graceful-fs"; import {promisify} from "node:util"; +import {gzip} from "node:zlib"; const mkdir = promisify(fs.mkdir); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -285,23 +286,11 @@ export default class CacheManager { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } - const cacheKey = this.#createKeyForStage(buildSignature, stageId, stageSignature, resourcePath); - const result = await cacache.get.info(this.#casDir, cacheKey); + // const cacheKey = this.#createKeyForStage(buildSignature, stageId, stageSignature, resourcePath, integrity); + const result = await cacache.get.info(this.#casDir, integrity); if (!result) { return null; } - if (result.integrity !== integrity) { - log.info(`Integrity mismatch for cache entry ` + - `${cacheKey}: expected ${integrity}, got ${result.integrity}`); - - const res = await cacache.get.byDigest(this.#casDir, integrity); - if (res) { - log.info(`Updating cache entry with expectation...`); - await this.writeStage(buildSignature, stageId, resourcePath, res); - return await this.getResourcePathForStage( - buildSignature, stageId, stageSignature, resourcePath, integrity); - } - } return result.path; } @@ -324,64 +313,17 @@ export default class CacheManager { async writeStageResource(buildSignature, stageId, stageSignature, resource) { // Check if resource has already been written const integrity = await resource.getIntegrity(); - const hasResource = await cacache.get.hasContent(this.#casDir, integrity); - const cacheKey = this.#createKeyForStage(buildSignature, stageId, stageSignature, resource.getOriginalPath()); + const hasResource = await cacache.get.info(this.#casDir, integrity); if (!hasResource) { const buffer = await resource.getBuffer(); + // Compress the buffer using gzip before caching + const compressedBuffer = await promisify(gzip)(buffer); await cacache.put( this.#casDir, - cacheKey, - buffer, + integrity, + compressedBuffer, CACACHE_OPTIONS ); - } else { - // Update index - await cacache.index.insert(this.#casDir, cacheKey, integrity, CACACHE_OPTIONS); } } - - // async writeStage(buildSignature, stageId, resourcePath, buffer) { - // return await cacache.put( - // this.#casDir, - // this.#createKeyForStage(buildSignature, stageId, resourcePath), - // buffer, - // CACACHE_OPTIONS - // ); - // } - - // async writeStageStream(buildSignature, stageId, resourcePath, stream) { - // const writable = cacache.put.stream( - // this.#casDir, - // this.#createKeyForStage(buildSignature, stageId, resourcePath), - // stream, - // CACACHE_OPTIONS, - // ); - // return new Promise((resolve, reject) => { - // writable.on("integrity", (digest) => { - // resolve(digest); - // }); - // writable.on("error", (err) => { - // reject(err); - // }); - // stream.pipe(writable); - // }); - // } - - /** - * Creates a cache key for a resource in a specific stage - * - * The key format is: buildSignature|stageId|stageSignature|resourcePath - * This ensures unique identification of resources across different builds, - * stages, and input combinations. - * - * @private - * @param {string} buildSignature - Build signature hash - * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature - Stage signature hash - * @param {string} resourcePath - Virtual path of the resource - * @returns {string} Cache key string - */ - #createKeyForStage(buildSignature, stageId, stageSignature, resourcePath) { - return `${buildSignature}|${stageId}|${stageSignature}|${resourcePath}`; - } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 5fdc8006c2a..120f2f74df9 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -2,6 +2,7 @@ import {createResource, createProxy} from "@ui5/fs/resourceFactory"; import {getLogger} from "@ui5/logger"; import fs from "graceful-fs"; import {promisify} from "node:util"; +import {gunzip, createGunzip} from "node:zlib"; const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; import StageCache from "./StageCache.js"; @@ -616,10 +617,13 @@ export default class ProjectBuildCache { fsPath: cachePath }, createStream: () => { - return fs.createReadStream(cachePath); + // Decompress the gzip-compressed stream + return fs.createReadStream(cachePath).pipe(createGunzip()); }, createBuffer: async () => { - return await readFile(cachePath); + // Decompress the gzip-compressed buffer + const compressedBuffer = await readFile(cachePath); + return await promisify(gunzip)(compressedBuffer); }, size, lastModified, From 21ee135e7f1d20b286c8ad8015a36751da61a6d4 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 2 Jan 2026 11:08:59 +0100 Subject: [PATCH 048/223] refactor(fs): Ensure writer collection uses unique readers --- packages/fs/lib/WriterCollection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fs/lib/WriterCollection.js b/packages/fs/lib/WriterCollection.js index a9286a424b7..51f49f21f5d 100644 --- a/packages/fs/lib/WriterCollection.js +++ b/packages/fs/lib/WriterCollection.js @@ -62,7 +62,7 @@ class WriterCollection extends AbstractReaderWriter { this._writerMapping = writerMapping; this._readerCollection = new ReaderCollection({ name: `Reader collection of writer collection '${this._name}'`, - readers: Object.values(writerMapping) + readers: Array.from(new Set(Object.values(writerMapping))) // Ensure unique readers }); } From 4eea3871b7a7d61b7ae95fd6ddfb4fc13cfe9ed7 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 2 Jan 2026 11:12:06 +0100 Subject: [PATCH 049/223] refactor(fs): Remove write tracking from MonitoredReaderWriter Not needed in current implementation --- packages/fs/lib/MonitoredReaderWriter.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/fs/lib/MonitoredReaderWriter.js b/packages/fs/lib/MonitoredReaderWriter.js index 4a42c2980d6..0472fb9b610 100644 --- a/packages/fs/lib/MonitoredReaderWriter.js +++ b/packages/fs/lib/MonitoredReaderWriter.js @@ -5,7 +5,6 @@ export default class MonitoredReaderWriter extends AbstractReaderWriter { #sealed = false; #paths = new Set(); #patterns = new Set(); - #pathsWritten = new Set(); constructor(readerWriter) { super(readerWriter.getName()); @@ -20,11 +19,6 @@ export default class MonitoredReaderWriter extends AbstractReaderWriter { }; } - getWrittenResourcePaths() { - this.#sealed = true; - return this.#pathsWritten; - } - async _byGlob(virPattern, options, trace) { if (this.#sealed) { throw new Error(`Unexpected read operation after reader has been sealed`); @@ -54,13 +48,6 @@ export default class MonitoredReaderWriter extends AbstractReaderWriter { } async _write(resource, options) { - if (this.#sealed) { - throw new Error(`Unexpected write operation after writer has been sealed`); - } - if (!resource) { - throw new Error(`Cannot write undefined resource`); - } - this.#pathsWritten.add(resource.getOriginalPath()); return this.#readerWriter.write(resource, options); } } From 33743947c99a6f750ec48aa5d17e93c3e66371a2 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 2 Jan 2026 11:12:47 +0100 Subject: [PATCH 050/223] refactor(project): Identify written resources using stage writer --- packages/project/lib/build/TaskRunner.js | 1 - .../lib/build/cache/ProjectBuildCache.js | 19 ++++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index f5c833ede47..0f74e715f64 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -243,7 +243,6 @@ class TaskRunner { } this._log.endTask(taskName); await this._buildCache.recordTaskResult(taskName, - workspace.getWrittenResourcePaths(), workspace.getResourceRequests(), dependencies?.getResourceRequests()); }; diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 120f2f74df9..088c51d83b6 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -230,14 +230,13 @@ export default class ProjectBuildCache { * 4. Removes the task from the invalidated tasks list * * @param {string} taskName - Name of the executed task - * @param {Set} writtenResourcePaths - Set of resource paths written by the task * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} projectResourceRequests * Resource requests for project resources * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} dependencyResourceRequests * Resource requests for dependency resources * @returns {Promise} */ - async recordTaskResult(taskName, writtenResourcePaths, projectResourceRequests, dependencyResourceRequests) { + async recordTaskResult(taskName, projectResourceRequests, dependencyResourceRequests) { if (!this.#taskCache.has(taskName)) { // Initialize task cache this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, this.#buildSignature)); @@ -253,17 +252,11 @@ export default class ProjectBuildCache { this.#dependencyReader ); - // TODO: Read written resources from writer instead of relying on monitor? - // const stage = this.#project.getStage(); - // const stageWriter = stage.getWriter(); - // const writer = stageWriter.collection ? stageWriter.collection : stageWriter; - // const writtenResources = await writer.byGlob("/**/*"); - // if (writtenResources.length !== writtenResourcePaths.size) { - // throw new Error( - // `Mismatch between recorded written resources (${writtenResourcePaths.size}) ` + - // `and actual resources in stage (${writtenResources.length}) for task ${taskName} ` + - // `in project ${this.#project.getName()}`); - // } + // Identify resources written by task + const stage = this.#project.getStage(); + const stageWriter = stage.getWriter(); + const writtenResources = await stageWriter.byGlob("/**/*"); + const writtenResourcePaths = writtenResources.map((res) => res.getOriginalPath()); log.verbose(`Storing stage for task ${taskName} in project ${this.#project.getName()} ` + `with signature ${stageSignature}`); From 22bd2a04026ba80b1e642135360127472d4403a8 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 2 Jan 2026 15:46:16 +0100 Subject: [PATCH 051/223] refactor(project): Add basic differential update functionality --- packages/project/lib/build/TaskRunner.js | 43 +-- .../project/lib/build/cache/BuildTaskCache.js | 289 ++++++++++++------ .../project/lib/build/cache/CacheManager.js | 63 +++- .../lib/build/cache/ProjectBuildCache.js | 122 +++++--- .../project/lib/build/cache/index/HashTree.js | 66 +++- .../lib/build/cache/index/ResourceIndex.js | 53 +++- .../lib/build/cache/index/TreeRegistry.js | 58 +++- packages/project/lib/build/cache/utils.js | 1 + .../lib/build/definitions/application.js | 3 + .../lib/build/definitions/component.js | 3 + .../project/lib/build/definitions/library.js | 4 + .../lib/build/definitions/themeLibrary.js | 2 + .../project/lib/build/helpers/WatchHandler.js | 6 +- .../project/lib/specifications/Project.js | 7 +- .../lib/specifications/extensions/Task.js | 7 + .../lib/build/cache/index/TreeRegistry.js | 278 ++++++++++++++++- 16 files changed, 803 insertions(+), 202 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 0f74e715f64..b23a80dd2dd 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -174,10 +174,13 @@ class TaskRunner { * @param {string} taskName Name of the task which should be in the list availableTasks. * @param {object} [parameters] * @param {boolean} [parameters.requiresDependencies] + * @param {boolean} [parameters.supportsDifferentialUpdates] * @param {object} [parameters.options] * @param {Function} [parameters.taskFunction] */ - _addTask(taskName, {requiresDependencies = false, options = {}, taskFunction} = {}) { + _addTask(taskName, { + requiresDependencies = false, supportsDifferentialUpdates = false, options = {}, taskFunction + } = {}) { if (this._tasks[taskName]) { throw new Error(`Failed to add duplicate task ${taskName} for project ${this._project.getName()}`); } @@ -195,13 +198,12 @@ class TaskRunner { options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); // TODO: Apply cache and stage handling for custom tasks as well - const requiresRun = await this._buildCache.prepareTaskExecution(taskName, requiresDependencies); - if (!requiresRun) { + const cacheInfo = await this._buildCache.prepareTaskExecution(taskName, requiresDependencies); + if (cacheInfo === true) { this._log.skipTask(taskName); return; } - - const expectedOutput = new Set(); // TODO: Determine expected output properly + const usingCache = supportsDifferentialUpdates && cacheInfo; this._log.info( `Executing task ${taskName} for project ${this._project.getName()}`); @@ -209,18 +211,6 @@ class TaskRunner { const params = { workspace, taskUtil: this._taskUtil, - cacheUtil: { - // TODO: Create a proper interface for this - hasCache: () => { - return this._buildCache.hasTaskCache(taskName); - }, - getChangedProjectResourcePaths: () => { - return this._buildCache.getChangedProjectResourcePaths(taskName); - }, - getChangedDependencyResourcePaths: () => { - return this._buildCache.getChangedDependencyResourcePaths(taskName); - }, - }, options, }; @@ -229,11 +219,19 @@ class TaskRunner { dependencies = createMonitor(this._allDependenciesReader); params.dependencies = dependencies; } - + if (usingCache) { + this._log.info( + `Using differential update for task ${taskName} of project ${this._project.getName()}`); + // workspace = + params.changedProjectResourcePaths = Array.from(cacheInfo.changedProjectResourcePaths); + if (requiresDependencies) { + params.changedDependencyResourcePaths = Array.from(cacheInfo.changedDependencyResourcePaths); + } + } if (!taskFunction) { - taskFunction = (await this._taskRepository.getTask(taskName)).task; + const {task} = await this._taskRepository.getTask(taskName); + taskFunction = task; } - this._log.startTask(taskName); this._taskStart = performance.now(); await taskFunction(params); @@ -244,7 +242,8 @@ class TaskRunner { this._log.endTask(taskName); await this._buildCache.recordTaskResult(taskName, workspace.getResourceRequests(), - dependencies?.getResourceRequests()); + dependencies?.getResourceRequests(), + usingCache ? cacheInfo : undefined); }; } this._tasks[taskName] = { @@ -319,6 +318,7 @@ class TaskRunner { const requiredDependenciesCallback = await task.getRequiredDependenciesCallback(); const getBuildSignatureCallback = await task.getBuildSignatureCallback(); const getExpectedOutputCallback = await task.getExpectedOutputCallback(); + const differentialUpdateCallback = await task.getDifferentialUpdateCallback(); const specVersion = task.getSpecVersion(); let requiredDependencies; @@ -392,6 +392,7 @@ class TaskRunner { provideDependenciesReader, getBuildSignatureCallback, getExpectedOutputCallback, + differentialUpdateCallback, getDependenciesReader: () => { // Create the dependencies reader on-demand return this._createDependenciesReader(requiredDependencies); diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 86756dc5b72..70a9184e74f 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -1,9 +1,9 @@ import micromatch from "micromatch"; -// import {getLogger} from "@ui5/logger"; +import {getLogger} from "@ui5/logger"; import ResourceRequestGraph, {Request} from "./ResourceRequestGraph.js"; import ResourceIndex from "./index/ResourceIndex.js"; import TreeRegistry from "./index/TreeRegistry.js"; -// const log = getLogger("build:cache:BuildTaskCache"); +const log = getLogger("build:cache:BuildTaskCache"); /** * @typedef {object} @ui5/project/build/cache/BuildTaskCache~ResourceRequests @@ -36,33 +36,46 @@ import TreeRegistry from "./index/TreeRegistry.js"; * to reuse existing resource indices, optimizing both memory and computation. */ export default class BuildTaskCache { - // #projectName; #taskName; + #projectName; #resourceRequests; + #readTaskMetadataCache; #treeRegistries = []; + #useDifferentialUpdate = true; // ===== LIFECYCLE ===== /** * Creates a new BuildTaskCache instance * - * @param {string} projectName - Name of the project (currently unused but reserved for logging) * @param {string} taskName - Name of the task this cache manages - * @param {string} buildSignature - Build signature for the current build (currently unused but reserved) - * @param {TaskCacheMetadata} [metadata] - Previously cached metadata to restore from. - * If provided, reconstructs the resource request graph from serialized data. - * If omitted, starts with an empty request graph. + * @param {string} projectName - Name of the project this task belongs to + * @param {Function} readTaskMetadataCache - Function to read cached task metadata */ - constructor(projectName, taskName, buildSignature, metadata) { - // this.#projectName = projectName; + constructor(taskName, projectName, readTaskMetadataCache) { this.#taskName = taskName; + this.#projectName = projectName; + this.#readTaskMetadataCache = readTaskMetadataCache; + } - if (metadata) { - this.#resourceRequests = ResourceRequestGraph.fromCacheObject(metadata.requestSetGraph); - } else { + async #initResourceRequests() { + if (this.#resourceRequests) { + return; // Already initialized + } + if (!this.#readTaskMetadataCache) { + // No cache reader provided, start with empty graph this.#resourceRequests = new ResourceRequestGraph(); + return; + } + + const taskMetadata = + await this.#readTaskMetadataCache(); + if (!taskMetadata) { + throw new Error(`No cached metadata found for task '${this.#taskName}' ` + + `of project '${this.#projectName}'`); } + this.#resourceRequests = this.#restoreGraphFromCache(taskMetadata); } // ===== METADATA ACCESS ===== @@ -76,30 +89,6 @@ export default class BuildTaskCache { return this.#taskName; } - /** - * Gets all possible stage signatures for this task - * - * Returns signatures from all recorded request sets. Each signature represents - * a unique combination of resources that were accessed during task execution. - * Used to look up cached build stages. - * - * @param {module:@ui5/fs.AbstractReader} [projectReader] - Reader for project resources (currently unused) - * @param {module:@ui5/fs.AbstractReader} [dependencyReader] - Reader for dependency resources (currently unused) - * @returns {Promise} Array of stage signature strings - * @throws {Error} If resource index is missing for any request set - */ - async getPossibleStageSignatures(projectReader, dependencyReader) { - const requestSetIds = this.#resourceRequests.getAllNodeIds(); - const signatures = requestSetIds.map((requestSetId) => { - const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); - if (!resourceIndex) { - throw new Error(`Resource index missing for request set ID ${requestSetId}`); - } - return resourceIndex.getSignature(); - }); - return signatures; - } - /** * Updates resource indices for request sets affected by changed resources * @@ -119,6 +108,7 @@ export default class BuildTaskCache { * @returns {Promise} */ async updateIndices(changedProjectResourcePaths, changedDepResourcePaths, projectReader, dependencyReader) { + await this.#initResourceRequests(); // Filter relevant resource changes and update the indices if necessary const matchingRequestSetIds = []; const updatesByRequestSetId = new Map(); @@ -142,11 +132,6 @@ export default class BuildTaskCache { } } if (relevantUpdates.length) { - if (!this.#resourceRequests.getMetadata(nodeId).resourceIndex) { - // Restore missing resource index - await this.#restoreResourceIndex(nodeId, projectReader, dependencyReader); - continue; // Index is fresh now, no need to update again - } updatesByRequestSetId.set(nodeId, relevantUpdates); matchingRequestSetIds.push(nodeId); } @@ -156,6 +141,9 @@ export default class BuildTaskCache { // Update matching resource indices for (const requestSetId of matchingRequestSetIds) { const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Missing resource index for request set ID ${requestSetId}`); + } const resourcePathsToUpdate = updatesByRequestSetId.get(requestSetId); const resourcesToUpdate = []; @@ -186,44 +174,11 @@ export default class BuildTaskCache { await resourceIndex.upsertResources(resourcesToUpdate); } } - return await this.#flushTreeRegistries(); - } - - /** - * Restores a missing resource index for a request set - * - * Recursively restores parent indices first, then derives or creates the index - * for the current request set. Uses tree derivation when a parent index exists - * to share common resources efficiently. - * - * @private - * @param {number} requestSetId - ID of the request set to restore - * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for project resources - * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for dependency resources - * @returns {Promise} The restored resource index - */ - async #restoreResourceIndex(requestSetId, projectReader, dependencyReader) { - const node = this.#resourceRequests.getNode(requestSetId); - const addedRequests = node.getAddedRequests(); - const parentId = node.getParentId(); - let resourceIndex; - if (parentId) { - let {resourceIndex: parentResourceIndex} = this.#resourceRequests.getMetadata(parentId); - if (!parentResourceIndex) { - // Restore parent index first - parentResourceIndex = await this.#restoreResourceIndex(parentId, projectReader, dependencyReader); - } - // Add resources from delta to index - const resourcesToAdd = this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); - resourceIndex = parentResourceIndex.deriveTree(resourcesToAdd); + if (this.#useDifferentialUpdate) { + return await this.#flushTreeChangesWithDiff(changedProjectResourcePaths); } else { - const resourcesRead = - await this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); - resourceIndex = await ResourceIndex.create(resourcesRead, this.#newTreeRegistry()); + return await this.#flushTreeChanges(changedProjectResourcePaths); } - const metadata = this.#resourceRequests.getMetadata(requestSetId); - metadata.resourceIndex = resourceIndex; - return resourceIndex; } /** @@ -266,6 +221,31 @@ export default class BuildTaskCache { return matchedResources; } + /** + * Gets all possible stage signatures for this task + * + * Returns signatures from all recorded request sets. Each signature represents + * a unique combination of resources that were accessed during task execution. + * Used to look up cached build stages. + * + * @param {module:@ui5/fs.AbstractReader} [projectReader] - Reader for project resources (currently unused) + * @param {module:@ui5/fs.AbstractReader} [dependencyReader] - Reader for dependency resources (currently unused) + * @returns {Promise} Array of stage signature strings + * @throws {Error} If resource index is missing for any request set + */ + async getPossibleStageSignatures(projectReader, dependencyReader) { + await this.#initResourceRequests(); + const requestSetIds = this.#resourceRequests.getAllNodeIds(); + const signatures = requestSetIds.map((requestSetId) => { + const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Resource index missing for request set ID ${requestSetId}`); + } + return resourceIndex.getSignature(); + }); + return signatures; + } + /** * Calculates a signature for the task based on accessed resources * @@ -286,6 +266,7 @@ export default class BuildTaskCache { * @returns {Promise} Signature hash string of the resource index */ async calculateSignature(projectRequests, dependencyRequests, projectReader, dependencyReader) { + await this.#initResourceRequests(); const requests = []; for (const pathRead of projectRequests.paths) { requests.push(new Request("path", pathRead)); @@ -354,10 +335,94 @@ export default class BuildTaskCache { * Must be called after operations that schedule updates via registries. * * @private - * @returns {Promise} + * @returns {Promise} Object containing sets of added, updated, and removed resource paths */ - async #flushTreeRegistries() { - await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); + async #flushTreeChanges() { + return await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); + } + + /** + * Flushes all tree registries to apply batched updates + * + * Commits all pending tree modifications across all registries in parallel. + * Must be called after operations that schedule updates via registries. + * + * @param {Set} projectResourcePaths Set of changed project resource paths + * @private + * @returns {Promise} Object containing sets of added, updated, and removed resource paths + */ + async #flushTreeChangesWithDiff(projectResourcePaths) { + const requestSetIds = this.#resourceRequests.getAllNodeIds(); + const trees = new Map(); + // Record current signatures and create mapping between trees and request sets + requestSetIds.map((requestSetId) => { + const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Resource index missing for request set ID ${requestSetId}`); + } + trees.set(resourceIndex.getTree(), { + requestSetId, + signature: resourceIndex.getSignature(), + }); + }); + + let greatestNumberOfChanges = 0; + let relevantTree; + let relevantStats; + const res = await this.#flushTreeChanges(); + + // Based on the returned stats, find the tree with the greatest difference + // If none of the updated trees lead to a valid cache, this tree can be used to execute a differential + // build (assuming there's a cache for its previous signature) + for (const {treeStats} of res) { + for (const [tree, stats] of treeStats) { + if (stats.removed.length > 0) { + // If resources have been removed, we currently decide to not rely on any cache + return; + } + const numberOfChanges = stats.added.length + stats.updated.length; + if (numberOfChanges > greatestNumberOfChanges) { + greatestNumberOfChanges = numberOfChanges; + relevantTree = tree; + relevantStats = stats; + } + } + } + + if (!relevantTree) { + return; + } + // Update signatures for affected request sets + const {requestSetId, signature: originalSignature} = trees.get(relevantTree); + const newSignature = relevantTree.getRootHash(); + log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + + `updated resource index for request set ID ${requestSetId} ` + + `from signature ${originalSignature} ` + + `to ${newSignature}`); + + const changedProjectResourcePaths = new Set(); + const changedDependencyResourcePaths = new Set(); + for (const path of relevantStats.added) { + if (projectResourcePaths.has(path)) { + changedProjectResourcePaths.add(path); + } else { + changedDependencyResourcePaths.add(path); + } + } + for (const path of relevantStats.updated) { + if (projectResourcePaths.has(path)) { + changedProjectResourcePaths.add(path); + } else { + changedDependencyResourcePaths.add(path); + } + } + + return { + originalSignature, + newSignature, + changedProjectResourcePaths, + changedDependencyResourcePaths, + }; } /** @@ -415,8 +480,6 @@ export default class BuildTaskCache { return resourcesMap.values(); } - // ===== VALIDATION ===== - /** * Checks if changed resources match this task's tracked resources * @@ -427,7 +490,8 @@ export default class BuildTaskCache { * @param {string[]} dependencyResourcePaths - Changed dependency resource paths * @returns {boolean} True if any changed resources match this task's tracked resources */ - matchesChangedResources(projectResourcePaths, dependencyResourcePaths) { + async matchesChangedResources(projectResourcePaths, dependencyResourcePaths) { + await this.#initResourceRequests(); const resourceRequests = this.#resourceRequests.getAllRequests(); return resourceRequests.some(({type, value}) => { if (type === "path") { @@ -455,8 +519,63 @@ export default class BuildTaskCache { * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph */ toCacheObject() { + const rootIndices = []; + const deltaIndices = []; + for (const {nodeId, parentId} of this.#resourceRequests.traverseByDepth()) { + const {resourceIndex} = this.#resourceRequests.getMetadata(nodeId); + if (!resourceIndex) { + throw new Error(`Missing resource index for node ID ${nodeId}`); + } + if (!parentId) { + rootIndices.push({ + nodeId, + resourceIndex: resourceIndex.toCacheObject(), + }); + } else { + const rootResourceIndex = this.#resourceRequests.getMetadata(parentId); + if (!rootResourceIndex) { + throw new Error(`Missing root resource index for parent ID ${parentId}`); + } + const addedResourceIndex = resourceIndex.getAddedResourceIndex(rootResourceIndex); + deltaIndices.push({ + nodeId, + addedResourceIndex, + }); + } + } return { - requestSetGraph: this.#resourceRequests.toCacheObject() + requestSetGraph: this.#resourceRequests.toCacheObject(), + rootIndices, + deltaIndices, }; } + + #restoreGraphFromCache({requestSetGraph, rootIndices, deltaIndices}) { + const resourceRequests = ResourceRequestGraph.fromCacheObject(requestSetGraph); + const registries = new Map(); + // Restore root resource indices + for (const {nodeId, resourceIndex: serializedIndex} of rootIndices) { + const metadata = resourceRequests.getMetadata(nodeId); + const registry = this.#newTreeRegistry(); + registries.set(nodeId, registry); + metadata.resourceIndex = ResourceIndex.fromCache(serializedIndex, registry); + } + // Restore delta resource indices + if (deltaIndices) { + for (const {nodeId, addedResourceIndex} of deltaIndices) { + const node = resourceRequests.getNode(nodeId); + const {resourceIndex: parentResourceIndex} = resourceRequests.getMetadata(node.getParentId()); + const registry = registries.get(node.getParentId()); + if (!registry) { + throw new Error(`Missing tree registry for parent of node ID ${nodeId}`); + } + const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex, registry); + + resourceRequests.setMetadata(nodeId, { + resourceIndex, + }); + } + } + return resourceRequests; + } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index f2d7c565d90..72fa6f17a3c 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -158,7 +158,7 @@ export default class CacheManager { * @param {string} buildSignature - Build signature hash * @returns {string} Absolute path to the index metadata file */ - #getIndexMetadataPath(packageName, buildSignature) { + #getIndexCachePath(packageName, buildSignature) { const pkgDir = getPathFromPackageName(packageName); return path.join(this.#indexDir, pkgDir, `${buildSignature}.json`); } @@ -176,7 +176,7 @@ export default class CacheManager { */ async readIndexCache(projectId, buildSignature) { try { - const metadata = await readFile(this.#getIndexMetadataPath(projectId, buildSignature), "utf8"); + const metadata = await readFile(this.#getIndexCachePath(projectId, buildSignature), "utf8"); return JSON.parse(metadata); } catch (err) { if (err.code === "ENOENT") { @@ -199,7 +199,7 @@ export default class CacheManager { * @returns {Promise} */ async writeIndexCache(projectId, buildSignature, index) { - const indexPath = this.#getIndexMetadataPath(projectId, buildSignature); + const indexPath = this.#getIndexCachePath(projectId, buildSignature); await mkdir(path.dirname(indexPath), {recursive: true}); await writeFile(indexPath, JSON.stringify(index, null, 2), "utf8"); } @@ -267,6 +267,63 @@ export default class CacheManager { await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); } + /** + * Generates the file path for stage metadata + * + * @private + * @param {string} packageName - Package/project identifier + * @param {string} buildSignature - Build signature hash + * @param {string} taskName + * @returns {string} Absolute path to the stage metadata file + */ + #getTaskMetadataPath(packageName, buildSignature, taskName) { + const pkgDir = getPathFromPackageName(packageName); + return path.join(this.#stageMetadataDir, pkgDir, buildSignature, taskName, `metadata.json`); + } + + /** + * Reads stage metadata from cache + * + * Stage metadata contains information about resources produced by a build stage, + * including resource paths and their metadata. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {string} taskName + * @returns {Promise} Parsed stage metadata or null if not found + * @throws {Error} If file read fails for reasons other than file not existing + */ + async readTaskMetadata(projectId, buildSignature, taskName) { + try { + const metadata = await readFile(this.#getTaskMetadataPath(projectId, buildSignature, taskName), "utf8"); + return JSON.parse(metadata); + } catch (err) { + if (err.code === "ENOENT") { + // Cache miss + return null; + } + throw err; + } + } + + /** + * Writes stage metadata to cache + * + * Persists metadata about resources produced by a build stage. + * Creates parent directories if needed. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {string} taskName + * @param {object} metadata - Stage metadata object to serialize + * @returns {Promise} + */ + async writeTaskMetadata(projectId, buildSignature, taskName, metadata) { + const metadataPath = this.#getTaskMetadataPath(projectId, buildSignature, taskName); + await mkdir(path.dirname(metadataPath), {recursive: true}); + await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + } + /** * Retrieves the file system path for a cached resource * diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 088c51d83b6..9a774e990d3 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -103,9 +103,10 @@ export default class ProjectBuildCache { await ResourceIndex.fromCacheWithDelta(indexCache, resources); // Import task caches - for (const [taskName, metadata] of Object.entries(indexCache.taskMetadata)) { + for (const taskName of indexCache.taskList) { this.#taskCache.set(taskName, - new BuildTaskCache(this.#project.getName(), taskName, this.#buildSignature, metadata)); + new BuildTaskCache(taskName, this.#project.getName(), + this.#createBuildTaskCacheMetadataReader(taskName))); } if (changedPaths.length) { // Invalidate tasks based on changed resources @@ -114,7 +115,7 @@ export default class ProjectBuildCache { // Since no tasks have been invalidated, a rebuild is still necessary in this case, so that // each task can find and use its individual stage cache. // Hence requiresInitialBuild will be set to true in this case (and others. - this.resourceChanged(changedPaths, []); + await this.resourceChanged(changedPaths, []); } else if (indexCache.indexTree.root.hash !== resourceIndex.getSignature()) { // Validate index signature matches with cached signature throw new Error( @@ -140,7 +141,7 @@ export default class ProjectBuildCache { * * @param {string} taskName - Name of the task to prepare * @param {boolean} requiresDependencies - Whether the task requires dependency reader - * @returns {Promise} True if task needs execution, false if cached result can be used + * @returns {Promise} True or object if task can use cache, false otherwise */ async prepareTaskExecution(taskName, requiresDependencies) { const stageName = this.#getStageNameForTask(taskName); @@ -149,35 +150,50 @@ export default class ProjectBuildCache { this.#project.useStage(stageName); if (taskCache) { + let deltaInfo; if (this.#invalidatedTasks.has(taskName)) { - const {changedProjectResourcePaths, changedDependencyResourcePaths} = + const invalidationInfo = this.#invalidatedTasks.get(taskName); - await taskCache.updateIndices( - changedProjectResourcePaths, changedDependencyResourcePaths, + deltaInfo = await taskCache.updateIndices( + invalidationInfo.changedProjectResourcePaths, + invalidationInfo.changedDependencyResourcePaths, this.#project.getReader(), this.#dependencyReader); } // else: Index will be created upon task completion // After index update, try to find cached stages for the new signatures - const stageCache = await this.#findStageCache(taskCache, stageName); + const stageSignatures = await taskCache.getPossibleStageSignatures(); + const stageCache = await this.#findStageCache(stageName, stageSignatures); if (stageCache) { - // TODO: This might cause more changed resources for following tasks - this.#project.setStage(stageName, stageCache.stage); + const stageChanged = this.#project.setStage(stageName, stageCache.stage); // Task can be skipped, use cached stage as project reader if (this.#invalidatedTasks.has(taskName)) { this.#invalidatedTasks.delete(taskName); } - if (stageCache.writtenResourcePaths.size) { + if (!stageChanged && stageCache.writtenResourcePaths.size) { // Invalidate following tasks this.#invalidateFollowingTasks(taskName, stageCache.writtenResourcePaths); } - return false; // No need to execute the task + return true; // No need to execute the task + } else if (deltaInfo) { + log.verbose(`No cached stage found for task ${taskName} in project ${this.#project.getName()}`); + + const deltaStageCache = await this.#findStageCache(stageName, [deltaInfo.originalSignature]); + if (deltaStageCache) { + log.verbose(`Using delta cached stage for task ${taskName} in project ${this.#project.getName()}`); + return { + previousStageCache: deltaStageCache, + newSignature: deltaInfo.newSignature, + changedProjectResourcePaths: deltaInfo.changedProjectResourcePaths, + changedDependencyResourcePaths: deltaInfo.changedDependencyResourcePaths + }; + } } } // No cached stage found, store current project reader for later use in recordTaskResult this.#currentProjectReader = this.#project.getReader(); - return true; // Task needs to be executed + return false; // Task needs to be executed } /** @@ -187,13 +203,12 @@ export default class ProjectBuildCache { * stage signature. Returns the first matching cached stage found. * * @private - * @param {BuildTaskCache} taskCache - Task cache containing possible stage signatures * @param {string} stageName - Name of the stage to find + * @param {string[]} stageSignatures - Possible signatures for the stage * @returns {Promise} Cached stage entry or null if not found */ - async #findStageCache(taskCache, stageName) { + async #findStageCache(stageName, stageSignatures) { // Check cache exists and ensure it's still valid before using it - const stageSignatures = await taskCache.getPossibleStageSignatures(); log.verbose(`Looking for cached stage for task ${stageName} in project ${this.#project.getName()} ` + `with ${stageSignatures.length} possible signatures:\n - ${stageSignatures.join("\n - ")}`); if (stageSignatures.length) { @@ -234,30 +249,52 @@ export default class ProjectBuildCache { * Resource requests for project resources * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} dependencyResourceRequests * Resource requests for dependency resources + * @param {object} cacheInfo * @returns {Promise} */ - async recordTaskResult(taskName, projectResourceRequests, dependencyResourceRequests) { + async recordTaskResult(taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo) { if (!this.#taskCache.has(taskName)) { // Initialize task cache - this.#taskCache.set(taskName, new BuildTaskCache(this.#project.getName(), taskName, this.#buildSignature)); + this.#taskCache.set(taskName, new BuildTaskCache(taskName, this.#project.getName())); } log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); const taskCache = this.#taskCache.get(taskName); - // Calculate signature for executed task - const stageSignature = await taskCache.calculateSignature( - projectResourceRequests, - dependencyResourceRequests, - this.#currentProjectReader, - this.#dependencyReader - ); - // Identify resources written by task const stage = this.#project.getStage(); const stageWriter = stage.getWriter(); const writtenResources = await stageWriter.byGlob("/**/*"); const writtenResourcePaths = writtenResources.map((res) => res.getOriginalPath()); + let stageSignature; + if (cacheInfo) { + stageSignature = cacheInfo.newSignature; + // Add resources from previous stage cache to current stage + let reader; + if (cacheInfo.previousStageCache.stage.byGlob) { + // Reader instance + reader = cacheInfo.previousStageCache.stage; + } else { + // Stage instance + reader = cacheInfo.previousStageCache.stage.getWriter() ?? + cacheInfo.previousStageCache.stage.getReader(); + } + const previousWrittenResources = await reader.byGlob("/**/*"); + for (const res of previousWrittenResources) { + if (!writtenResourcePaths.includes(res.getOriginalPath())) { + await stageWriter.write(res); + } + } + } else { + // Calculate signature for executed task + stageSignature = await taskCache.calculateSignature( + projectResourceRequests, + dependencyResourceRequests, + this.#currentProjectReader, + this.#dependencyReader + ); + } + log.verbose(`Storing stage for task ${taskName} in project ${this.#project.getName()} ` + `with signature ${stageSignature}`); // Store resulting stage in stage cache @@ -289,9 +326,8 @@ export default class ProjectBuildCache { * @private * @param {string} taskName - Name of the task that wrote resources * @param {Set} writtenResourcePaths - Paths of resources written by the task - * @returns {void} */ - #invalidateFollowingTasks(taskName, writtenResourcePaths) { + async #invalidateFollowingTasks(taskName, writtenResourcePaths) { const writtenPathsArray = Array.from(writtenResourcePaths); // Check whether following tasks need to be invalidated @@ -299,7 +335,7 @@ export default class ProjectBuildCache { const taskIdx = allTasks.indexOf(taskName); for (let i = taskIdx + 1; i < allTasks.length; i++) { const nextTaskName = allTasks[i]; - if (!this.#taskCache.get(nextTaskName).matchesChangedResources(writtenPathsArray, [])) { + if (!await this.#taskCache.get(nextTaskName).matchesChangedResources(writtenPathsArray, [])) { continue; } if (this.#invalidatedTasks.has(nextTaskName)) { @@ -339,10 +375,10 @@ export default class ProjectBuildCache { * @param {string[]} dependencyResourcePaths - Changed dependency resource paths * @returns {boolean} True if any task was invalidated, false otherwise */ - resourceChanged(projectResourcePaths, dependencyResourcePaths) { + async resourceChanged(projectResourcePaths, dependencyResourcePaths) { let taskInvalidated = false; for (const [taskName, taskCache] of this.#taskCache) { - if (!taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { + if (!await taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { continue; } taskInvalidated = true; @@ -652,16 +688,11 @@ export default class ProjectBuildCache { // Store result stage await this.#writeResultStage(); - // Store index cache - const indexMetadata = this.#resourceIndex.toCacheObject(); - const taskMetadata = Object.create(null); + // Store task caches for (const [taskName, taskCache] of this.#taskCache) { - taskMetadata[taskName] = taskCache.toCacheObject(); + await this.#cacheManager.writeTaskMetadata(this.#project.getId(), this.#buildSignature, taskName, + taskCache.toCacheObject()); } - await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, { - ...indexMetadata, - taskMetadata, - }); // Store stage caches const stageQueue = this.#stageCache.flushCacheQueue(); @@ -689,6 +720,13 @@ export default class ProjectBuildCache { await this.#cacheManager.writeStageCache( this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); })); + + // Finally store index cache + const indexMetadata = this.#resourceIndex.toCacheObject(); + await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, { + ...indexMetadata, + taskList: Array.from(this.#taskCache.keys()), + }); } /** @@ -736,4 +774,10 @@ export default class ProjectBuildCache { }); } } + + #createBuildTaskCacheMetadataReader(taskName) { + return () => { + return this.#cacheManager.readTaskMetadata(this.#project.getId(), this.#buildSignature, taskName); + }; + } } diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index b1678bb41a9..6fcd6fd477d 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -627,8 +627,9 @@ export default class HashTree { * Skips resources whose metadata hasn't changed (optimization). * * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to upsert - * @returns {Promise<{added: Array, updated: Array, unchanged: Array, scheduled?: Array}>} - * Status report: arrays of paths by operation type. 'scheduled' is present when using registry. + * @returns {Promise<{added: Array, updated: Array, unchanged: Array}|undefined>} + * Status report: arrays of paths by operation type. + * Undefined if using registry (results determined during flush). */ async upsertResources(resources) { if (!resources || resources.length === 0) { @@ -640,12 +641,7 @@ export default class HashTree { this.registry.scheduleUpsert(resource); } // When using registry, actual results are determined during flush - return { - added: [], - updated: [], - unchanged: [], - scheduled: resources.map((r) => r.getOriginalPath()) - }; + return; } // Immediate mode @@ -738,9 +734,9 @@ export default class HashTree { * sharing the affected directories (intentional for the shared view model). * * @param {Array} resourcePaths - Array of resource paths to remove - * @returns {Promise<{removed: Array, notFound: Array, scheduled?: Array}>} + * @returns {Promise<{removed: Array, notFound: Array}|undefined>} * Status report: 'removed' contains successfully removed paths, 'notFound' contains paths that didn't exist. - * 'scheduled' is present when using registry. + * Undefined if using registry (results determined during flush). */ async removeResources(resourcePaths) { if (!resourcePaths || resourcePaths.length === 0) { @@ -751,11 +747,7 @@ export default class HashTree { for (const resourcePath of resourcePaths) { this.registry.scheduleRemoval(resourcePath); } - return { - removed: [], - notFound: [], - scheduled: resourcePaths - }; + return; } // Immediate mode @@ -1100,4 +1092,48 @@ export default class HashTree { traverse(this.root, "/"); return paths.sort(); } + + /** + * For a tree derived from a base tree, get the list of resource nodes + * that were added compared to the base tree. + * + * @param {HashTree} rootTree - The base tree to compare against + * @returns {Array} Array of added resource nodes + */ + getAddedResources(rootTree) { + const added = []; + + const traverse = (node, currentPath, implicitlyAdded = false) => { + if (implicitlyAdded) { + if (node.type === "resource") { + added.push(node); + } + } else { + const baseNode = rootTree._findNode(currentPath); + if (baseNode && baseNode === node) { + // Node exists in base tree and is the same (structural sharing) + // Neither node nor children are added + return; + } else { + // Node doesn't exist in base tree - it's added + if (node.type === "resource") { + added.push(node); + } else { + // Directory - all children are added + implicitlyAdded = true; + } + } + } + + if (node.type === "directory") { + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + traverse(child, childPath, implicitlyAdded); + } + } + }; + + traverse(this.root, ""); + return added; + } } diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index b2b62448617..e318f683a67 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -52,7 +52,7 @@ export default class ResourceIndex { * signature calculation and change tracking. * * @param {Array<@ui5/fs/Resource>} resources - Resources to index - * @param {import("./TreeRegistry.js").default} [registry] - Optional tree registry for deduplication + * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise} A new resource index * @public */ @@ -77,13 +77,14 @@ export default class ResourceIndex { * @param {number} indexCache.indexTimestamp - Timestamp of cached index * @param {object} indexCache.indexTree - Cached hash tree structure * @param {Array<@ui5/fs/Resource>} resources - Current resources to compare against cache + * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise<{changedPaths: string[], resourceIndex: ResourceIndex}>} * Object containing array of all changed resource paths and the updated index * @public */ - static async fromCacheWithDelta(indexCache, resources) { + static async fromCacheWithDelta(indexCache, resources, registry) { const {indexTimestamp, indexTree} = indexCache; - const tree = HashTree.fromCache(indexTree, {indexTimestamp}); + const tree = HashTree.fromCache(indexTree, {indexTimestamp, registry}); const currentResourcePaths = new Set(resources.map((resource) => resource.getOriginalPath())); const removed = tree.getResourcePaths().filter((resourcePath) => { return !currentResourcePaths.has(resourcePath); @@ -104,25 +105,22 @@ export default class ResourceIndex { * and fast restoration is needed. * * @param {object} indexCache - Cached index object - * @param {Object} indexCache.resourceMetadata - - * Map of resource paths to metadata (integrity, lastModified, size) - * @param {import("./TreeRegistry.js").default} [registry] - Optional tree registry for deduplication + * @param {number} indexCache.indexTimestamp - Timestamp of cached index + * @param {object} indexCache.indexTree - Cached hash tree structure + * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise} Restored resource index * @public */ - static async fromCache(indexCache, registry) { - const resourceIndex = Object.entries(indexCache.resourceMetadata).map(([path, metadata]) => { - return { - path, - integrity: metadata.integrity, - lastModified: metadata.lastModified, - size: metadata.size, - }; - }); - const tree = new HashTree(resourceIndex, {registry}); + static fromCache(indexCache, registry) { + const {indexTimestamp, indexTree} = indexCache; + const tree = HashTree.fromCache(indexTree, {indexTimestamp, registry}); return new ResourceIndex(tree); } + getTree() { + return this.#tree; + } + /** * Creates a deep copy of this ResourceIndex. * @@ -153,6 +151,10 @@ export default class ResourceIndex { return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); } + async deriveTreeWithIndex(resourceIndex) { + return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); + } + /** * Updates existing resources in the index. * @@ -167,6 +169,25 @@ export default class ResourceIndex { return await this.#tree.updateResources(resources); } + /** + * Compares this index against a base index and returns metadata + * for resources that have been added in this index. + * + * @param {ResourceIndex} baseIndex - The base resource index to compare against + */ + getAddedResourceIndex(baseIndex) { + const addedResources = this.#tree.getAddedResources(baseIndex.getTree()); + return addedResources.map(((resource) => { + return { + path: resource.path, + integrity: resource.integrity, + size: resource.size, + lastModified: resource.lastModified, + inode: resource.inode, + }; + })); + } + /** * Inserts or updates resources in the index. * diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 92550b9ba52..dd2cfe058da 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -114,8 +114,9 @@ export default class TreeRegistry { * * After successful completion, all pending operations are cleared. * - * @returns {Promise<{added: string[], updated: string[], unchanged: string[], removed: string[]}>} - * Object containing arrays of resource paths categorized by operation result + * @returns {Promise<{added: string[], updated: string[], unchanged: string[], removed: string[], treeStats: Map}>} + * Object containing arrays of resource paths categorized by operation result, + * plus per-tree statistics showing which resource paths were added/updated/unchanged/removed in each tree */ async flush() { if (this.pendingUpserts.size === 0 && this.pendingRemovals.size === 0) { @@ -123,7 +124,8 @@ export default class TreeRegistry { added: [], updated: [], unchanged: [], - removed: [] + removed: [], + treeStats: new Map() }; } @@ -133,6 +135,12 @@ export default class TreeRegistry { const unchangedResources = []; const removedResources = []; + // Track per-tree statistics + const treeStats = new Map(); // tree -> {added: string[], updated: string[], unchanged: string[], removed: string[]} + for (const tree of this.trees) { + treeStats.set(tree, {added: [], updated: [], unchanged: [], removed: []}); + } + // Track which resource nodes we've already modified to handle shared nodes const modifiedNodes = new Set(); @@ -145,24 +153,33 @@ export default class TreeRegistry { const resourceName = parts[parts.length - 1]; const parentPath = parts.slice(0, -1).join(path.sep); + // Track which trees have this resource before deletion (for shared nodes) + const treesWithResource = []; for (const tree of this.trees) { const parentNode = tree._findNode(parentPath); - if (!parentNode || parentNode.type !== "directory") { - continue; + if (parentNode && parentNode.type === "directory" && parentNode.children.has(resourceName)) { + treesWithResource.push({tree, parentNode}); } + } - if (parentNode.children.has(resourceName)) { - parentNode.children.delete(resourceName); + // Perform deletion once and track for all trees that had it + if (treesWithResource.length > 0) { + const {parentNode} = treesWithResource[0]; + parentNode.children.delete(resourceName); + for (const {tree} of treesWithResource) { if (!affectedTrees.has(tree)) { affectedTrees.set(tree, new Set()); } this._markAncestorsAffected(tree, parts.slice(0, -1), affectedTrees); - if (!removedResources.includes(resourcePath)) { - removedResources.push(resourcePath); - } + // Track per-tree removal + treeStats.get(tree).removed.push(resourcePath); + } + + if (!removedResources.includes(resourcePath)) { + removedResources.push(resourcePath); } } } @@ -187,7 +204,8 @@ export default class TreeRegistry { // Ensure parent directory exists let parentNode = tree._findNode(parentPath); if (!parentNode) { - parentNode = this._ensureDirectoryPath(tree, parentPath.split(path.sep).filter((p) => p.length > 0)); + parentNode = this._ensureDirectoryPath( + tree, parentPath.split(path.sep).filter((p) => p.length > 0)); } if (parentNode.type !== "directory") { @@ -211,6 +229,9 @@ export default class TreeRegistry { modifiedNodes.add(resourceNode); dirModified = true; + // Track per-tree addition + treeStats.get(tree).added.push(upsert.fullPath); + if (!addedResources.includes(upsert.fullPath)) { addedResources.push(upsert.fullPath); } @@ -238,15 +259,24 @@ export default class TreeRegistry { modifiedNodes.add(resourceNode); dirModified = true; + // Track per-tree update + treeStats.get(tree).updated.push(upsert.fullPath); + if (!updatedResources.includes(upsert.fullPath)) { updatedResources.push(upsert.fullPath); } } else { + // Track per-tree unchanged + treeStats.get(tree).unchanged.push(upsert.fullPath); + if (!unchangedResources.includes(upsert.fullPath)) { unchangedResources.push(upsert.fullPath); } } } else { + // Node was already modified by another tree (shared node) + // Still count it as an update for this tree since the change affects it + treeStats.get(tree).updated.push(upsert.fullPath); dirModified = true; } } @@ -266,7 +296,8 @@ export default class TreeRegistry { } tree._computeHash(parentNode); - this._markAncestorsAffected(tree, parentPath.split(path.sep).filter((p) => p.length > 0), affectedTrees); + this._markAncestorsAffected( + tree, parentPath.split(path.sep).filter((p) => p.length > 0), affectedTrees); } } } @@ -297,7 +328,8 @@ export default class TreeRegistry { added: addedResources, updated: updatedResources, unchanged: unchangedResources, - removed: removedResources + removed: removedResources, + treeStats }; } diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 6da9489eaed..3925660e357 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -105,6 +105,7 @@ export async function createResourceIndex(resources, includeInode = false) { integrity: await resource.getIntegrity(), lastModified: resource.getLastModified(), size: await resource.getSize(), + inode: await resource.getInode(), }; if (includeInode) { resourceMetadata.inode = resource.getInode(); diff --git a/packages/project/lib/build/definitions/application.js b/packages/project/lib/build/definitions/application.js index c546ee9d6bf..86873606872 100644 --- a/packages/project/lib/build/definitions/application.js +++ b/packages/project/lib/build/definitions/application.js @@ -20,6 +20,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { + supportsDifferentialUpdates: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,json}" @@ -27,6 +28,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { + supportsDifferentialUpdates: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json}" @@ -42,6 +44,7 @@ export default function({project, taskUtil, getTask}) { } } tasks.set("minify", { + supportsDifferentialUpdates: true, options: { pattern: minificationPattern } diff --git a/packages/project/lib/build/definitions/component.js b/packages/project/lib/build/definitions/component.js index 48684b6df03..3fd7711855f 100644 --- a/packages/project/lib/build/definitions/component.js +++ b/packages/project/lib/build/definitions/component.js @@ -20,6 +20,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { + supportsDifferentialUpdates: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,json}" @@ -27,6 +28,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { + supportsDifferentialUpdates: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json}" @@ -41,6 +43,7 @@ export default function({project, taskUtil, getTask}) { } tasks.set("minify", { + supportsDifferentialUpdates: true, options: { pattern: minificationPattern } diff --git a/packages/project/lib/build/definitions/library.js b/packages/project/lib/build/definitions/library.js index 9b92177d1cd..3f9b31ea5f2 100644 --- a/packages/project/lib/build/definitions/library.js +++ b/packages/project/lib/build/definitions/library.js @@ -20,6 +20,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { + supportsDifferentialUpdates: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,library,css,less,theme,html}" @@ -27,6 +28,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { + supportsDifferentialUpdates: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json,library,css,less,theme,html}" @@ -34,6 +36,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceBuildtime", { + supportsDifferentialUpdates: true, options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" } @@ -82,6 +85,7 @@ export default function({project, taskUtil, getTask}) { } tasks.set("minify", { + supportsDifferentialUpdates: true, options: { pattern: minificationPattern } diff --git a/packages/project/lib/build/definitions/themeLibrary.js b/packages/project/lib/build/definitions/themeLibrary.js index 2acf0392768..4b70d01b872 100644 --- a/packages/project/lib/build/definitions/themeLibrary.js +++ b/packages/project/lib/build/definitions/themeLibrary.js @@ -11,6 +11,7 @@ export default function({project, taskUtil, getTask}) { const tasks = new Map(); tasks.set("replaceCopyright", { + supportsDifferentialUpdates: true, options: { copyright: project.getCopyright(), pattern: "/resources/**/*.{less,theme}" @@ -18,6 +19,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { + supportsDifferentialUpdates: true, options: { version: project.getVersion(), pattern: "/resources/**/*.{less,theme}" diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 81e83a6d6f8..a33012f0aaa 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -104,15 +104,15 @@ class WatchHandler extends EventEmitter { } } - await graph.traverseDepthFirst(({project}) => { + await graph.traverseDepthFirst(async ({project}) => { if (!sourceChanges.has(project) && !dependencyChanges.has(project)) { return; } const projectSourceChanges = Array.from(sourceChanges.get(project) ?? new Set()); const projectDependencyChanges = Array.from(dependencyChanges.get(project) ?? new Set()); const projectBuildContext = this.#buildContext.getBuildContext(project.getName()); - const tasksInvalidated = - projectBuildContext.getBuildCache().resourceChanged(projectSourceChanges, projectDependencyChanges); + const tasksInvalidated = await projectBuildContext.getBuildCache() + .resourceChanged(projectSourceChanges, projectDependencyChanges); if (tasksInvalidated) { someProjectTasksInvalidated = true; diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 239dc81bf0b..cbb0f206a53 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -462,19 +462,22 @@ class Project extends Specification { if (stageOrCacheReader instanceof Stage) { newStage = stageOrCacheReader; if (oldStage === newStage) { - // No change - return; + // Same stage as before + return false; // Stored stage has not changed } } else { newStage = new Stage(stageId, undefined, stageOrCacheReader); } this.#stages[stageIdx] = newStage; + + // Update current stage reference if necessary if (oldStage === this.#currentStage) { this.#currentStage = newStage; // Unset "current" reader/writer. They might be outdated this.#currentStageReaders = new Map(); this.#currentStageWorkspace = null; } + return true; // Indicate that the stored stage has changed } setResultStage(reader) { diff --git a/packages/project/lib/specifications/extensions/Task.js b/packages/project/lib/specifications/extensions/Task.js index dfb88fc83ec..e8cefcc7a94 100644 --- a/packages/project/lib/specifications/extensions/Task.js +++ b/packages/project/lib/specifications/extensions/Task.js @@ -38,6 +38,13 @@ class Task extends Extension { return (await this._getImplementation()).determineBuildSignature; } + /** + * @public + */ + async getDifferentialUpdateCallback() { + return (await this._getImplementation()).differentialUpdate; + } + /** * @public */ diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js index 8d9e7d70480..9dcbb0fd6d6 100644 --- a/packages/project/test/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -410,9 +410,7 @@ test("upsertResources - with registry schedules operations", async (t) => { createMockResource("b.js", "hash-b", Date.now(), 1024, 1) ]); - t.deepEqual(result.scheduled, ["b.js"], "Should report scheduled paths"); - t.deepEqual(result.added, [], "Should have empty added in scheduled mode"); - t.deepEqual(result.updated, [], "Should have empty updated in scheduled mode"); + t.is(result, undefined, "Should return undefined in scheduled mode"); }); test("upsertResources - with registry and flush", async (t) => { @@ -466,8 +464,7 @@ test("removeResources - with registry schedules operations", async (t) => { const result = await tree.removeResources(["b.js"]); - t.deepEqual(result.scheduled, ["b.js"], "Should report scheduled paths"); - t.deepEqual(result.removed, [], "Should have empty removed in scheduled mode"); + t.is(result, undefined, "Should return undefined in scheduled mode"); }); test("removeResources - with registry and flush", async (t) => { @@ -565,3 +562,274 @@ test("upsertResources and removeResources - conflicting operations on same path" t.true(result.updated.includes("a.js") || result.changed.includes("a.js"), "Should update or keep a.js"); t.truthy(tree.hasPath("a.js"), "Tree should still have a.js"); }); + +// ============================================================================ +// Per-Tree Statistics Tests +// ============================================================================ + +test("TreeRegistry - flush returns per-tree statistics", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const tree2 = new HashTree([{path: "b.js", integrity: "hash-b"}], {registry}); + + // Update tree1 resource + registry.scheduleUpdate(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); + // Add new resource - gets added to all trees + registry.scheduleUpsert(createMockResource("c.js", "hash-c", Date.now(), 2048, 2)); + + const result = await registry.flush(); + + // Verify global results + // a.js gets updated in tree1 but added to tree2 (didn't exist before) + t.true(result.updated.includes("a.js"), "Should report a.js as updated"); + t.true(result.added.includes("c.js"), "Should report c.js as added"); + t.true(result.added.includes("a.js"), "Should report a.js as added to tree2"); + + // Verify per-tree statistics + t.truthy(result.treeStats, "Should have treeStats"); + t.is(result.treeStats.size, 2, "Should have stats for both trees"); + + const stats1 = result.treeStats.get(tree1); + const stats2 = result.treeStats.get(tree2); + + t.truthy(stats1, "Should have stats for tree1"); + t.truthy(stats2, "Should have stats for tree2"); + + // Tree1: 1 update to a.js, 1 add for c.js + t.is(stats1.updated.length, 1, "Tree1 should have 1 update (a.js)"); + t.true(stats1.updated.includes("a.js"), "Tree1 should have a.js in updated"); + t.is(stats1.added.length, 1, "Tree1 should have 1 addition (c.js)"); + t.true(stats1.added.includes("c.js"), "Tree1 should have c.js in added"); + t.is(stats1.unchanged.length, 0, "Tree1 should have 0 unchanged"); + t.is(stats1.removed.length, 0, "Tree1 should have 0 removals"); + + // Tree2: 1 add for c.js, 1 add for a.js (didn't exist in tree2) + t.is(stats2.updated.length, 0, "Tree2 should have 0 updates"); + t.is(stats2.added.length, 2, "Tree2 should have 2 additions (a.js, c.js)"); + t.true(stats2.added.includes("a.js"), "Tree2 should have a.js in added"); + t.true(stats2.added.includes("c.js"), "Tree2 should have c.js in added"); + t.is(stats2.unchanged.length, 0, "Tree2 should have 0 unchanged"); + t.is(stats2.removed.length, 0, "Tree2 should have 0 removals"); +}); + +test("TreeRegistry - per-tree statistics with shared nodes", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ], {registry}); + const tree2 = tree1.deriveTree([{path: "unique/c.js", integrity: "hash-c"}]); + + // Verify trees share the "shared" directory + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + t.is(sharedDir1, sharedDir2, "Should share the same 'shared' directory node"); + + // Update shared resource + registry.scheduleUpdate(createMockResource("shared/a.js", "new-hash-a", Date.now(), 1024, 1)); + + const result = await registry.flush(); + + // Verify global results + t.deepEqual(result.updated, ["shared/a.js"], "Should report shared/a.js as updated"); + + // Verify per-tree statistics + const stats1 = result.treeStats.get(tree1); + const stats2 = result.treeStats.get(tree2); + + // Both trees should count the update since they share the node + t.is(stats1.updated.length, 1, "Tree1 should count the shared update"); + t.true(stats1.updated.includes("shared/a.js"), "Tree1 should have shared/a.js in updated"); + t.is(stats2.updated.length, 1, "Tree2 should count the shared update"); + t.true(stats2.updated.includes("shared/a.js"), "Tree2 should have shared/a.js in updated"); + t.is(stats1.added.length, 0, "Tree1 should have 0 additions"); + t.is(stats2.added.length, 0, "Tree2 should have 0 additions"); + t.is(stats1.unchanged.length, 0, "Tree1 should have 0 unchanged"); + t.is(stats2.unchanged.length, 0, "Tree2 should have 0 unchanged"); + t.is(stats1.removed.length, 0, "Tree1 should have 0 removals"); + t.is(stats2.removed.length, 0, "Tree2 should have 0 removals"); +}); + +test("TreeRegistry - per-tree statistics with mixed operations", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"}, + {path: "c.js", integrity: "hash-c"} + ], {registry}); + const tree2 = tree1.deriveTree([{path: "d.js", integrity: "hash-d"}]); + + // Update a.js (affects both trees - shared) + registry.scheduleUpdate(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); + // Remove b.js (affects both trees - shared) + registry.scheduleRemoval("b.js"); + // Add e.js (affects both trees) + registry.scheduleUpsert(createMockResource("e.js", "hash-e", Date.now(), 2048, 5)); + // Update d.js (exists in tree2, will be added to tree1) + registry.scheduleUpdate(createMockResource("d.js", "new-hash-d", Date.now(), 1024, 4)); + + const result = await registry.flush(); + + // Verify per-tree statistics + const stats1 = result.treeStats.get(tree1); + const stats2 = result.treeStats.get(tree2); + + // Tree1: 1 update (a.js), 2 additions (e.js, d.js), 1 removal (b.js) + t.is(stats1.updated.length, 1, "Tree1 should have 1 update (a.js)"); + t.true(stats1.updated.includes("a.js"), "Tree1 should have a.js in updated"); + t.is(stats1.added.length, 2, "Tree1 should have 2 additions (e.js, d.js)"); + t.true(stats1.added.includes("e.js"), "Tree1 should have e.js in added"); + t.true(stats1.added.includes("d.js"), "Tree1 should have d.js in added"); + t.is(stats1.unchanged.length, 0, "Tree1 should have 0 unchanged"); + t.is(stats1.removed.length, 1, "Tree1 should have 1 removal (b.js)"); + t.true(stats1.removed.includes("b.js"), "Tree1 should have b.js in removed"); + + // Tree2: 2 updates (a.js shared, d.js), 1 addition (e.js), 1 removal (b.js shared) + t.is(stats2.updated.length, 2, "Tree2 should have 2 updates (a.js, d.js)"); + t.true(stats2.updated.includes("a.js"), "Tree2 should have a.js in updated"); + t.true(stats2.updated.includes("d.js"), "Tree2 should have d.js in updated"); + t.is(stats2.added.length, 1, "Tree2 should have 1 addition (e.js)"); + t.true(stats2.added.includes("e.js"), "Tree2 should have e.js in added"); + t.is(stats2.unchanged.length, 0, "Tree2 should have 0 unchanged"); + t.is(stats2.removed.length, 1, "Tree2 should have 1 removal (b.js)"); + t.true(stats2.removed.includes("b.js"), "Tree2 should have b.js in removed"); +}); + +test("TreeRegistry - per-tree statistics with no changes", async (t) => { + const registry = new TreeRegistry(); + const timestamp = Date.now(); + const tree1 = new HashTree([{ + path: "a.js", + integrity: "hash-a", + lastModified: timestamp, + size: 1024, + inode: 100 + }], {registry}); + const tree2 = new HashTree([{ + path: "b.js", + integrity: "hash-b", + lastModified: timestamp, + size: 2048, + inode: 200 + }], {registry}); + + // Schedule updates with unchanged metadata + // Note: These will add missing resources to the other tree + registry.scheduleUpdate(createMockResource("a.js", "hash-a", timestamp, 1024, 100)); + registry.scheduleUpdate(createMockResource("b.js", "hash-b", timestamp, 2048, 200)); + + const result = await registry.flush(); + + // a.js is unchanged in tree1 but added to tree2 + // b.js is unchanged in tree2 but added to tree1 + t.deepEqual(result.updated, [], "Should have no updates"); + t.true(result.added.includes("a.js"), "a.js should be added to tree2"); + t.true(result.added.includes("b.js"), "b.js should be added to tree1"); + t.true(result.unchanged.includes("a.js"), "a.js should be unchanged in tree1"); + t.true(result.unchanged.includes("b.js"), "b.js should be unchanged in tree2"); + + // Verify per-tree statistics + const stats1 = result.treeStats.get(tree1); + const stats2 = result.treeStats.get(tree2); + + // Tree1: a.js unchanged, b.js added + t.is(stats1.updated.length, 0, "Tree1 should have 0 updates"); + t.is(stats1.added.length, 1, "Tree1 should have 1 addition (b.js)"); + t.true(stats1.added.includes("b.js"), "Tree1 should have b.js in added"); + t.is(stats1.unchanged.length, 1, "Tree1 should have 1 unchanged (a.js)"); + t.true(stats1.unchanged.includes("a.js"), "Tree1 should have a.js in unchanged"); + t.is(stats1.removed.length, 0, "Tree1 should have 0 removals"); + + // Tree2: b.js unchanged, a.js added + t.is(stats2.updated.length, 0, "Tree2 should have 0 updates"); + t.is(stats2.added.length, 1, "Tree2 should have 1 addition (a.js)"); + t.true(stats2.added.includes("a.js"), "Tree2 should have a.js in added"); + t.is(stats2.unchanged.length, 1, "Tree2 should have 1 unchanged (b.js)"); + t.true(stats2.unchanged.includes("b.js"), "Tree2 should have b.js in unchanged"); + t.is(stats2.removed.length, 0, "Tree2 should have 0 removals"); +}); + +test("TreeRegistry - empty flush returns empty treeStats", async (t) => { + const registry = new TreeRegistry(); + new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + new HashTree([{path: "b.js", integrity: "hash-b"}], {registry}); + + // Flush without scheduling any operations + const result = await registry.flush(); + + t.truthy(result.treeStats, "Should have treeStats"); + t.is(result.treeStats.size, 0, "Should have empty treeStats when no operations"); + t.deepEqual(result.added, [], "Should have no additions"); + t.deepEqual(result.updated, [], "Should have no updates"); + t.deepEqual(result.removed, [], "Should have no removals"); +}); + +test("TreeRegistry - derived tree reflects base tree resource changes in statistics", async (t) => { + const registry = new TreeRegistry(); + + // Create base tree with some resources + const baseTree = new HashTree([ + {path: "shared/resource1.js", integrity: "hash1"}, + {path: "shared/resource2.js", integrity: "hash2"} + ], {registry}); + + // Derive a new tree from base tree (shares same registry) + // Note: deriveTree doesn't schedule the new resources, it adds them directly to the derived tree + const derivedTree = baseTree.deriveTree([ + {path: "derived/resource3.js", integrity: "hash3"} + ]); + + // Verify both trees are registered + t.is(registry.getTreeCount(), 2, "Registry should have both trees"); + + // Verify they share the same nodes + const sharedDir1 = baseTree.root.children.get("shared"); + const sharedDir2 = derivedTree.root.children.get("shared"); + t.is(sharedDir1, sharedDir2, "Both trees should share the 'shared' directory node"); + + // Update a resource that exists in base tree (and is shared with derived tree) + registry.scheduleUpdate(createMockResource("shared/resource1.js", "new-hash1", Date.now(), 2048, 100)); + + // Add a new resource to the shared path + registry.scheduleUpsert(createMockResource("shared/resource4.js", "hash4", Date.now(), 1024, 200)); + + // Remove a shared resource + registry.scheduleRemoval("shared/resource2.js"); + + const result = await registry.flush(); + + // Verify global results + t.deepEqual(result.updated, ["shared/resource1.js"], "Should report resource1 as updated"); + t.true(result.added.includes("shared/resource4.js"), "Should report resource4 as added"); + t.deepEqual(result.removed, ["shared/resource2.js"], "Should report resource2 as removed"); + + // Verify per-tree statistics + const baseStats = result.treeStats.get(baseTree); + const derivedStats = result.treeStats.get(derivedTree); + + // Base tree statistics + // Base tree will also get derived/resource3.js added via registry (since it processes all trees) + t.is(baseStats.updated.length, 1, "Base tree should have 1 update"); + t.true(baseStats.updated.includes("shared/resource1.js"), "Base tree should have resource1 in updated"); + // baseStats.added should include both resource4 and resource3 + t.true(baseStats.added.includes("shared/resource4.js"), "Base tree should have resource4 in added"); + t.is(baseStats.removed.length, 1, "Base tree should have 1 removal"); + t.true(baseStats.removed.includes("shared/resource2.js"), "Base tree should have resource2 in removed"); + + // Derived tree statistics - CRITICAL: should reflect the same changes for shared resources + // Note: resource4 shows as "updated" because it's added to an already-existing shared node that was modified + t.is(derivedStats.updated.length, 2, "Derived tree should have 2 updates (resource1 changed, resource4 added to shared dir)"); + t.true(derivedStats.updated.includes("shared/resource1.js"), "Derived tree should have resource1 in updated"); + t.true(derivedStats.updated.includes("shared/resource4.js"), "Derived tree should have resource4 in updated"); + t.is(derivedStats.added.length, 0, "Derived tree should have 0 additions tracked separately"); + t.is(derivedStats.removed.length, 1, "Derived tree should have 1 removal (shared resource2)"); + t.true(derivedStats.removed.includes("shared/resource2.js"), "Derived tree should have resource2 in removed"); + + // Verify the actual tree state + t.is(baseTree.getResourceByPath("shared/resource1.js").integrity, "new-hash1", "Base tree should have updated integrity"); + t.is(derivedTree.getResourceByPath("shared/resource1.js").integrity, "new-hash1", "Derived tree should have updated integrity (shared node)"); + t.truthy(baseTree.hasPath("shared/resource4.js"), "Base tree should have new resource"); + t.truthy(derivedTree.hasPath("shared/resource4.js"), "Derived tree should have new resource (shared)"); + t.false(baseTree.hasPath("shared/resource2.js"), "Base tree should not have removed resource"); + t.false(derivedTree.hasPath("shared/resource2.js"), "Derived tree should not have removed resource (shared)"); +}); From 13a69218a43d36776ef5c4aee0f06beb0f12e88e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 11:02:47 +0100 Subject: [PATCH 052/223] refactor(builder): Re-add cache handling in tasks This reverts commit 084bb393f580b0e03cb0515ffcdbd2a757f0c406. --- .../builder/lib/tasks/escapeNonAsciiCharacters.js | 9 +++++---- packages/builder/lib/tasks/minify.js | 12 +++++++++--- packages/builder/lib/tasks/replaceBuildtime.js | 12 +++++++++--- packages/builder/lib/tasks/replaceCopyright.js | 12 +++++++++--- packages/builder/lib/tasks/replaceVersion.js | 12 +++++++++--- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js index 81d967c4012..697b2425080 100644 --- a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js +++ b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js @@ -14,20 +14,21 @@ import nonAsciiEscaper from "../processors/nonAsciiEscaper.js"; * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files - * @param {Array} parameters.invalidatedResources List of invalidated resource paths + * @param {string[]} [parameters.changedProjectResourcePaths] Set of changed resource paths within the project. + * This is only set if a cache is used and changes have been detected. * @param {object} parameters.options Options * @param {string} parameters.options.pattern Glob pattern to locate the files to be processed * @param {string} parameters.options.encoding source file encoding either "UTF-8" or "ISO-8859-1" * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, invalidatedResources, options: {pattern, encoding}}) { +export default async function({workspace, changedProjectResourcePaths, options: {pattern, encoding}}) { if (!encoding) { throw new Error("[escapeNonAsciiCharacters] Mandatory option 'encoding' not provided"); } let allResources; - if (invalidatedResources) { - allResources = await Promise.all(invalidatedResources.map((resource) => workspace.byPath(resource))); + if (changedProjectResourcePaths) { + allResources = await Promise.all(changedProjectResourcePaths.map((resource) => workspace.byPath(resource))); } else { allResources = await workspace.byGlob(pattern); } diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index 069212db989..5fdccc8124f 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -16,7 +16,8 @@ import fsInterface from "@ui5/fs/fsInterface"; * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil - * @param {object} [parameters.cacheUtil] Cache utility instance + * @param {string[]} [parameters.changedProjectResourcePaths] Set of changed resource paths within the project. + * This is only set if a cache is used and changes have been detected. * @param {object} parameters.options Options * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @param {boolean} [parameters.options.omitSourceMapResources=false] Whether source map resources shall @@ -27,10 +28,15 @@ import fsInterface from "@ui5/fs/fsInterface"; * @returns {Promise} Promise resolving with undefined once data has been written */ export default async function({ - workspace, taskUtil, cacheUtil, + workspace, taskUtil, changedProjectResourcePaths, options: {pattern, omitSourceMapResources = false, useInputSourceMaps = true} }) { - const resources = await workspace.byGlob(pattern); + let resources; + if (changedProjectResourcePaths) { + resources = await Promise.all(changedProjectResourcePaths.map((resource) => workspace.byPath(resource))); + } else { + resources = await workspace.byGlob(pattern); + } if (resources.length === 0) { return; } diff --git a/packages/builder/lib/tasks/replaceBuildtime.js b/packages/builder/lib/tasks/replaceBuildtime.js index 8cbe83b5713..44498a09186 100644 --- a/packages/builder/lib/tasks/replaceBuildtime.js +++ b/packages/builder/lib/tasks/replaceBuildtime.js @@ -28,13 +28,19 @@ function getTimestamp() { * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files - * @param {object} [parameters.cacheUtil] Cache utility instance + * @param {string[]} [parameters.changedProjectResourcePaths] Set of changed resource paths within the project. + * This is only set if a cache is used and changes have been detected. * @param {object} parameters.options Options * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, cacheUtil, options: {pattern}}) { - const resources = await workspace.byGlob(pattern); +export default async function({workspace, changedProjectResourcePaths, options: {pattern}}) { + let resources; + if (changedProjectResourcePaths) { + resources = await Promise.all(changedProjectResourcePaths.map((resource) => workspace.byPath(resource))); + } else { + resources = await workspace.byGlob(pattern); + } const timestamp = getTimestamp(); const processedResources = await stringReplacer({ resources, diff --git a/packages/builder/lib/tasks/replaceCopyright.js b/packages/builder/lib/tasks/replaceCopyright.js index 103e43e3003..90daed02fd5 100644 --- a/packages/builder/lib/tasks/replaceCopyright.js +++ b/packages/builder/lib/tasks/replaceCopyright.js @@ -24,13 +24,14 @@ import stringReplacer from "../processors/stringReplacer.js"; * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files - * @param {object} [parameters.cacheUtil] Cache utility instance + * @param {string[]} [parameters.changedProjectResourcePaths] Set of changed resource paths within the project. + * This is only set if a cache is used and changes have been detected. * @param {object} parameters.options Options * @param {string} parameters.options.copyright Replacement copyright * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, cacheUtil, options: {copyright, pattern}}) { +export default async function({workspace, changedProjectResourcePaths, options: {copyright, pattern}}) { if (!copyright) { return; } @@ -38,7 +39,12 @@ export default async function({workspace, cacheUtil, options: {copyright, patter // Replace optional placeholder ${currentYear} with the current year copyright = copyright.replace(/(?:\$\{currentYear\})/, new Date().getFullYear()); - const resources = await workspace.byGlob(pattern); + let resources; + if (changedProjectResourcePaths) { + resources = await Promise.all(changedProjectResourcePaths.map((resource) => workspace.byPath(resource))); + } else { + resources = await workspace.byGlob(pattern); + } const processedResources = await stringReplacer({ resources, diff --git a/packages/builder/lib/tasks/replaceVersion.js b/packages/builder/lib/tasks/replaceVersion.js index b1cd2eb1d16..d30b0839dc6 100644 --- a/packages/builder/lib/tasks/replaceVersion.js +++ b/packages/builder/lib/tasks/replaceVersion.js @@ -14,14 +14,20 @@ import stringReplacer from "../processors/stringReplacer.js"; * * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files - * @param {object} parameters.cacheUtil Cache utility instance + * @param {string[]} [parameters.changedProjectResourcePaths] Set of changed resource paths within the project. + * This is only set if a cache is used and changes have been detected. * @param {object} parameters.options Options * @param {string} parameters.options.pattern Pattern to locate the files to be processed * @param {string} parameters.options.version Replacement version * @returns {Promise} Promise resolving with undefined once data has been written */ -export default async function({workspace, cacheUtil, options: {pattern, version}}) { - const resources = await workspace.byGlob(pattern); +export default async function({workspace, changedProjectResourcePaths, options: {pattern, version}}) { + let resources; + if (changedProjectResourcePaths) { + resources = await Promise.all(changedProjectResourcePaths.map((resource) => workspace.byPath(resource))); + } else { + resources = await workspace.byGlob(pattern); + } const processedResources = await stringReplacer({ resources, options: { From f508b04179ab107ea13324a84d15b5e2425f60c1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 13:24:29 +0100 Subject: [PATCH 053/223] refactor(project): Cleanup HashTree implementation --- .../project/lib/build/cache/index/HashTree.js | 166 ++---------------- .../lib/build/cache/index/ResourceIndex.js | 14 -- .../lib/build/cache/index/TreeRegistry.js | 7 +- .../test/lib/build/cache/ProjectBuildCache.js | 18 +- .../lib/build/cache/ResourceRequestGraph.js | 4 +- .../test/lib/build/cache/index/HashTree.js | 58 +++--- .../lib/build/cache/index/TreeRegistry.js | 9 +- 7 files changed, 60 insertions(+), 216 deletions(-) diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index 6fcd6fd477d..fcb73b83232 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -136,9 +136,9 @@ export default class HashTree { * @param {Array|null} resources * Initial resources to populate the tree. Each resource should have a path and optional metadata. * @param {object} options - * @param {TreeRegistry} [options.registry] - Optional registry for coordinated batch updates across multiple trees - * @param {number} [options.indexTimestamp] - Timestamp when the resource index was created (for metadata comparison) - * @param {TreeNode} [options._root] - Internal: pre-existing root node for derived trees (enables structural sharing) + * @param {TreeRegistry} [options.registry] Optional registry for coordinated batch updates across multiple trees + * @param {number} [options.indexTimestamp] Timestamp when the resource index was created (for metadata comparison) + * @param {TreeNode} [options._root] Internal: pre-existing root node for derived trees (enables structural sharing) */ constructor(resources = null, options = {}) { this.registry = options.registry || null; @@ -242,7 +242,6 @@ export default class HashTree { // Phase 2: Copy path from root down (copy-on-write) // Only copy directories that will have their children modified current = this.root; - let needsNewChild = false; for (let i = 0; i < parts.length - 1; i++) { const dirName = parts[i]; @@ -252,7 +251,6 @@ export default class HashTree { const newDir = new TreeNode(dirName, "directory"); current.children.set(dirName, newDir); current = newDir; - needsNewChild = true; } else if (i === parts.length - 2) { // This is the parent directory that will get the new resource // Copy it to avoid modifying shared structure @@ -403,6 +401,10 @@ export default class HashTree { return this.#indexTimestamp; } + _updateIndexTimestamp() { + this.#indexTimestamp = Date.now(); + } + /** * Find a node by path * @@ -453,89 +455,6 @@ export default class HashTree { return derived; } - /** - * Update a single resource and recompute affected hashes. - * - * When a registry is attached, schedules the update for batch processing. - * Otherwise, applies the update immediately and recomputes ancestor hashes. - * Skips update if resource metadata hasn't changed (optimization). - * - * @param {@ui5/fs/Resource} resource - Resource instance to update - * @returns {Promise>} Array containing the resource path if changed, empty array if unchanged - */ - async updateResource(resource) { - const resourcePath = resource.getOriginalPath(); - - // If registry is attached, schedule update instead of applying immediately - if (this.registry) { - this.registry.scheduleUpdate(resource); - return [resourcePath]; // Will be determined after flush - } - - // Fall back to immediate update - const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); - - if (parts.length === 0) { - throw new Error("Cannot update root directory"); - } - - // Navigate to parent directory - let current = this.root; - const pathToRoot = [current]; - - for (let i = 0; i < parts.length - 1; i++) { - const dirName = parts[i]; - - if (!current.children.has(dirName)) { - throw new Error(`Directory not found: ${parts.slice(0, i + 1).join("/")}`); - } - - current = current.children.get(dirName); - pathToRoot.push(current); - } - - // Update the resource - const resourceName = parts[parts.length - 1]; - const resourceNode = current.children.get(resourceName); - - if (!resourceNode) { - throw new Error(`Resource not found: ${resourcePath}`); - } - - if (resourceNode.type !== "resource") { - throw new Error(`Path is not a resource: ${resourcePath}`); - } - - // Create metadata object from current node state - const currentMetadata = { - integrity: resourceNode.integrity, - lastModified: resourceNode.lastModified, - size: resourceNode.size, - inode: resourceNode.inode - }; - - // Check whether resource actually changed - const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); - if (isUnchanged) { - return []; // No change - } - - // Update resource metadata - resourceNode.integrity = await resource.getIntegrity(); - resourceNode.lastModified = resource.getLastModified(); - resourceNode.size = await resource.getSize(); - resourceNode.inode = resource.getInode(); - - // Recompute hashes from resource up to root - this._computeHash(resourceNode); - - for (let i = pathToRoot.length - 1; i >= 0; i--) { - this._computeHash(pathToRoot[i]); - } - - return [resourcePath]; - } - /** * Update multiple resources efficiently. * @@ -613,6 +532,7 @@ export default class HashTree { } } + this._updateIndexTimestamp(); return changedResources; } @@ -721,6 +641,7 @@ export default class HashTree { } } + this._updateIndexTimestamp(); return {added, updated, unchanged}; } @@ -808,6 +729,7 @@ export default class HashTree { } } + this._updateIndexTimestamp(); return {removed, notFound}; } @@ -867,71 +789,6 @@ export default class HashTree { return currentHash !== previousHash; } - /** - * Get all resources in a directory (non-recursive). - * - * Useful for inspecting directory contents or performing directory-level operations. - * - * @param {string} dirPath - Path to directory - * @returns {Array<{name: string, path: string, type: string, hash: string}>} Array of directory entries sorted by name - * @throws {Error} If directory not found or path is not a directory - */ - listDirectory(dirPath) { - const node = this._findNode(dirPath); - if (!node) { - throw new Error(`Directory not found: ${dirPath}`); - } - if (node.type !== "directory") { - throw new Error(`Path is not a directory: ${dirPath}`); - } - - const items = []; - for (const [name, child] of node.children) { - items.push({ - name, - path: path.join(dirPath, name), - type: child.type, - hash: child.hash.toString("hex") - }); - } - - return items.sort((a, b) => a.name.localeCompare(b.name)); - } - - /** - * Get all resources recursively. - * - * Returns complete resource metadata including paths, integrity hashes, and file stats. - * Useful for full tree inspection or export. - * - * @returns {Array<{path: string, integrity?: string, hash: string, lastModified?: number, size?: number, inode?: number}>} - * Array of all resources with metadata, sorted by path - */ - getAllResources() { - const resources = []; - - const traverse = (node, currentPath) => { - if (node.type === "resource") { - resources.push({ - path: currentPath, - integrity: node.integrity, - hash: node.hash.toString("hex"), - lastModified: node.lastModified, - size: node.size, - inode: node.inode - }); - } else { - for (const [name, child] of node.children) { - const childPath = currentPath ? path.join(currentPath, name) : name; - traverse(child, childPath); - } - } - }; - - traverse(this.root, "/"); - return resources.sort((a, b) => a.path.localeCompare(b.path)); - } - /** * Get tree statistics. * @@ -1001,6 +858,8 @@ export default class HashTree { /** * Validate tree structure and hashes * + * Currently unused, but possibly useful future integrity checks. + * * @returns {boolean} */ validate() { @@ -1035,6 +894,7 @@ export default class HashTree { return true; } + /** * Create a deep clone of this tree. * diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index e318f683a67..6256958d655 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -237,18 +237,4 @@ export default class ResourceIndex { indexTree: this.#tree.toCacheObject(), }; } - - // #getResourceMetadata() { - // const resources = this.#tree.getAllResources(); - // const resourceMetadata = Object.create(null); - // for (const resource of resources) { - // resourceMetadata[resource.path] = { - // lastModified: resource.lastModified, - // size: resource.size, - // integrity: resource.integrity, - // inode: resource.inode, - // }; - // } - // return resourceMetadata; - // } } diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index dd2cfe058da..1831d5753e0 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -114,7 +114,9 @@ export default class TreeRegistry { * * After successful completion, all pending operations are cleared. * - * @returns {Promise<{added: string[], updated: string[], unchanged: string[], removed: string[], treeStats: Map}>} + * @returns {Promise<{added: string[], updated: string[], unchanged: string[], removed: string[], + * treeStats: Map}>} * Object containing arrays of resource paths categorized by operation result, * plus per-tree statistics showing which resource paths were added/updated/unchanged/removed in each tree */ @@ -136,7 +138,7 @@ export default class TreeRegistry { const removedResources = []; // Track per-tree statistics - const treeStats = new Map(); // tree -> {added: string[], updated: string[], unchanged: string[], removed: string[]} + const treeStats = new Map(); for (const tree of this.trees) { treeStats.set(tree, {added: [], updated: [], unchanged: [], removed: []}); } @@ -318,6 +320,7 @@ export default class TreeRegistry { tree._computeHash(node); } } + tree._updateIndexTimestamp(); } // Clear all pending operations diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index ccc5989da35..83527bb4c15 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -270,14 +270,16 @@ test("recordTaskResult: removes task from invalidated list", async (t) => { await cache.prepareTaskExecution("task1", false); // Record initial result - await cache.recordTaskResult("task1", new Set(), {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); + await cache.recordTaskResult("task1", new Set(), + {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); // Invalidate task cache.resourceChanged(["/test.js"], []); // Re-execute and record await cache.prepareTaskExecution("task1", false); - await cache.recordTaskResult("task1", new Set(), {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); + await cache.recordTaskResult("task1", new Set(), + {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks after re-execution"); }); @@ -494,18 +496,6 @@ test("Throw error on build signature mismatch", async (t) => { "Throws error on signature mismatch" ); }); - -// ===== HELPER FUNCTION TESTS ===== - -test("firstTruthy: returns first truthy value from promises", async (t) => { - const {default: ProjectBuildCacheModule} = await import("../../../../lib/build/cache/ProjectBuildCache.js"); - - // Access the firstTruthy function through dynamic evaluation - // Since it's not exported, we test it indirectly through the module's behavior - // This test verifies the behavior exists without direct access - t.pass("firstTruthy is used internally for cache lookups"); -}); - // ===== EDGE CASES ===== test("Create cache with empty project name", async (t) => { diff --git a/packages/project/test/lib/build/cache/ResourceRequestGraph.js b/packages/project/test/lib/build/cache/ResourceRequestGraph.js index 6d99af2f660..2a410cc7567 100644 --- a/packages/project/test/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/test/lib/build/cache/ResourceRequestGraph.js @@ -178,7 +178,7 @@ test("ResourceRequestGraph: getMaterializedRequests returns full set", (t) => { new Request("path", "a.js"), new Request("path", "b.js") ]; - const node1 = graph.addRequestSet(set1); + graph.addRequestSet(set1); const set2 = [ new Request("path", "a.js"), @@ -545,7 +545,7 @@ test("ResourceRequestGraph: findBestParent chooses optimal parent", (t) => { new Request("path", "a.js"), new Request("path", "b.js") ]; - const node1 = graph.addRequestSet(set1); + graph.addRequestSet(set1); const set2 = [ new Request("path", "x.js"), diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index 75fc57efaa7..8b524b50fed 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -69,8 +69,8 @@ test("Updating resources in two trees produces same root hash", async (t) => { // Update same resource in both trees const resource = createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1); - await tree1.updateResource(resource); - await tree2.updateResource(resource); + await tree1.updateResources([resource]); + await tree2.updateResources([resource]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should have same root hash after identical updates"); @@ -89,13 +89,13 @@ test("Multiple updates in same order produce same root hash", async (t) => { const indexTimestamp = tree1.getIndexTimestamp(); // Update multiple resources in same order - await tree1.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); - await tree1.updateResource(createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)); - await tree1.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + await tree1.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree1.updateResources([createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)]); + await tree1.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); - await tree2.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); - await tree2.updateResource(createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)); - await tree2.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + await tree2.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree2.updateResources([createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)]); + await tree2.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should have same root hash after same sequence of updates"); @@ -113,13 +113,13 @@ test("Multiple updates in different order produce same root hash", async (t) => const indexTimestamp = tree1.getIndexTimestamp(); // Update in different orders - await tree1.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); - await tree1.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); - await tree1.updateResource(createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)); + await tree1.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree1.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); + await tree1.updateResources([createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)]); - await tree2.updateResource(createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)); - await tree2.updateResource(createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)); - await tree2.updateResource(createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)); + await tree2.updateResources([createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)]); + await tree2.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree2.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should have same root hash regardless of update order"); @@ -137,8 +137,8 @@ test("Batch updates produce same hash as individual updates", async (t) => { const indexTimestamp = tree1.getIndexTimestamp(); // Individual updates - await tree1.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); - await tree1.updateResource(createMockResource("file2.js", "new-hash2", indexTimestamp + 1, 201, 2)); + await tree1.updateResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); + await tree1.updateResources([createMockResource("file2.js", "new-hash2", indexTimestamp + 1, 201, 2)]); // Batch update const resources = [ @@ -161,7 +161,7 @@ test("Updating resource changes root hash", async (t) => { const originalHash = tree.getRootHash(); const indexTimestamp = tree.getIndexTimestamp(); - await tree.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); + await tree.updateResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); const newHash = tree.getRootHash(); t.not(originalHash, newHash, @@ -179,8 +179,8 @@ test("Updating resource back to original value restores original hash", async (t const indexTimestamp = tree.getIndexTimestamp(); // Update and then revert - await tree.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); - await tree.updateResource(createMockResource("file1.js", "hash1", 1000, 100, 1)); + await tree.updateResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); + await tree.updateResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); t.is(tree.getRootHash(), originalHash, "Root hash should be restored when resource is reverted to original value"); @@ -193,7 +193,9 @@ test("updateResource returns changed resource path", async (t) => { const tree = new HashTree(resources); const indexTimestamp = tree.getIndexTimestamp(); - const changed = await tree.updateResource(createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)); + const changed = await tree.updateResources([ + createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1) + ]); t.deepEqual(changed, ["file1.js"], "Should return path of changed resource"); }); @@ -204,7 +206,7 @@ test("updateResource returns empty array when integrity unchanged", async (t) => ]; const tree = new HashTree(resources); - const changed = await tree.updateResource(createMockResource("file1.js", "hash1", 1000, 100, 1)); + const changed = await tree.updateResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); t.deepEqual(changed, [], "Should return empty array when integrity unchanged"); }); @@ -216,7 +218,7 @@ test("updateResource does not change hash when integrity unchanged", async (t) = const tree = new HashTree(resources); const originalHash = tree.getRootHash(); - await tree.updateResource(createMockResource("file1.js", "hash1", 1000, 100, 1)); + await tree.updateResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); t.is(tree.getRootHash(), originalHash, "Hash should not change when integrity unchanged"); }); @@ -284,12 +286,12 @@ test("Updating unrelated resource doesn't affect consistency", async (t) => { const tree2 = new HashTree(initialResources); // Update different resources - await tree1.updateResource(createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)); - await tree2.updateResource(createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)); + await tree1.updateResources([createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)]); + await tree2.updateResources([createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)]); // Update an unrelated resource in both - await tree1.updateResource(createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)); - await tree2.updateResource(createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)); + await tree1.updateResources([createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)]); + await tree2.updateResources([createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should remain consistent after updating multiple resources"); @@ -534,9 +536,9 @@ test("deriveTree - changes propagate to derived trees (shared view)", async (t) // When tree1 is updated, tree2 sees the change (filtered view behavior) const indexTimestamp = tree1.getIndexTimestamp(); - await tree1.updateResource( + await tree1.updateResources([ createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) - ); + ]); // Both trees see the update as per design const node1 = tree1.root.children.get("shared").children.get("a.js"); diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js index 9dcbb0fd6d6..455c863ffa5 100644 --- a/packages/project/test/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -818,7 +818,8 @@ test("TreeRegistry - derived tree reflects base tree resource changes in statist // Derived tree statistics - CRITICAL: should reflect the same changes for shared resources // Note: resource4 shows as "updated" because it's added to an already-existing shared node that was modified - t.is(derivedStats.updated.length, 2, "Derived tree should have 2 updates (resource1 changed, resource4 added to shared dir)"); + t.is(derivedStats.updated.length, 2, + "Derived tree should have 2 updates (resource1 changed, resource4 added to shared dir)"); t.true(derivedStats.updated.includes("shared/resource1.js"), "Derived tree should have resource1 in updated"); t.true(derivedStats.updated.includes("shared/resource4.js"), "Derived tree should have resource4 in updated"); t.is(derivedStats.added.length, 0, "Derived tree should have 0 additions tracked separately"); @@ -826,8 +827,10 @@ test("TreeRegistry - derived tree reflects base tree resource changes in statist t.true(derivedStats.removed.includes("shared/resource2.js"), "Derived tree should have resource2 in removed"); // Verify the actual tree state - t.is(baseTree.getResourceByPath("shared/resource1.js").integrity, "new-hash1", "Base tree should have updated integrity"); - t.is(derivedTree.getResourceByPath("shared/resource1.js").integrity, "new-hash1", "Derived tree should have updated integrity (shared node)"); + t.is(baseTree.getResourceByPath("shared/resource1.js").integrity, "new-hash1", + "Base tree should have updated integrity"); + t.is(derivedTree.getResourceByPath("shared/resource1.js").integrity, "new-hash1", + "Derived tree should have updated integrity (shared node)"); t.truthy(baseTree.hasPath("shared/resource4.js"), "Base tree should have new resource"); t.truthy(derivedTree.hasPath("shared/resource4.js"), "Derived tree should have new resource (shared)"); t.false(baseTree.hasPath("shared/resource2.js"), "Base tree should not have removed resource"); From ade0088849ce40244a61208a241b480ea3990eb8 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 14:03:15 +0100 Subject: [PATCH 054/223] refactor(project): Make WatchHandler wait for build to finish before triggering again --- .../project/lib/build/helpers/WatchHandler.js | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index a33012f0aaa..726d8b48c55 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -15,6 +15,7 @@ class WatchHandler extends EventEmitter { #updateBuildResult; #abortControllers = []; #sourceChanges = new Map(); + #updateInProgress = false; #fileChangeHandlerTimeout; constructor(buildContext, updateBuildResult) { @@ -64,7 +65,7 @@ class WatchHandler extends EventEmitter { } } - async #fileChanged(project, filePath) { + #fileChanged(project, filePath) { // Collect changes (grouped by project), then trigger callbacks const resourcePath = project.getVirtualPath(filePath); if (!this.#sourceChanges.has(project)) { @@ -72,20 +73,38 @@ class WatchHandler extends EventEmitter { } this.#sourceChanges.get(project).add(resourcePath); + this.#queueHandleResourceChanges(); + } + + #queueHandleResourceChanges() { + if (this.#updateInProgress) { + // Prevent concurrent updates + return; + } + // Trigger callbacks debounced if (this.#fileChangeHandlerTimeout) { clearTimeout(this.#fileChangeHandlerTimeout); } this.#fileChangeHandlerTimeout = setTimeout(async () => { - await this.#handleResourceChanges(); this.#fileChangeHandlerTimeout = null; + + const sourceChanges = this.#sourceChanges; + // Reset file changes before processing + this.#sourceChanges = new Map(); + + this.#updateInProgress = true; + await this.#handleResourceChanges(sourceChanges); + this.#updateInProgress = false; + + if (this.#sourceChanges.size > 0) { + // New changes have occurred during processing, trigger queue again + this.#queueHandleResourceChanges(); + } }, 100); } - async #handleResourceChanges() { - // Reset file changes before processing - const sourceChanges = this.#sourceChanges; - this.#sourceChanges = new Map(); + async #handleResourceChanges(sourceChanges) { const dependencyChanges = new Map(); let someProjectTasksInvalidated = false; From c67b9535171f916f65f5be5c211b6d9a74b9fc3f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 14:42:27 +0100 Subject: [PATCH 055/223] refactor(project): Use cleanup hooks in update builds --- packages/project/lib/build/ProjectBuilder.js | 62 +++++++++++++------ packages/project/lib/build/TaskRunner.js | 8 +-- .../project/lib/build/cache/CacheManager.js | 20 ++++-- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 8de36819e61..a94d87a262e 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -181,7 +181,6 @@ class ProjectBuilder { } const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects); - const cleanupSigHooks = this._registerCleanupSigHooks(); let fsTarget; if (destPath) { fsTarget = resourceFactory.createAdapter({ @@ -191,7 +190,6 @@ class ProjectBuilder { } const queue = []; - const alreadyBuilt = []; // Create build queue based on graph depth-first search to ensure correct build order await this._graph.traverseDepthFirst(async ({project}) => { @@ -202,15 +200,38 @@ class ProjectBuilder { // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); - if (!await projectBuildContext.requiresBuild()) { - alreadyBuilt.push(projectName); - } } }); + if (destPath && cleanDest) { + this.#log.info(`Cleaning target directory...`); + await rmrf(destPath); + } + + await this.#build(queue, projectBuildContexts, requestedProjects, fsTarget); + + if (watch) { + const relevantProjects = queue.map((projectBuildContext) => { + return projectBuildContext.getProject(); + }); + return this._buildContext.initWatchHandler(relevantProjects, async () => { + await this.#updateBuild(projectBuildContexts, requestedProjects, fsTarget); + }); + } + } + + async #build(queue, projectBuildContexts, requestedProjects, fsTarget) { this.#log.setProjects(queue.map((projectBuildContext) => { return projectBuildContext.getProject().getName(); })); + + const alreadyBuilt = []; + for (const projectBuildContext of queue) { + if (!await projectBuildContext.requiresBuild()) { + const projectName = projectBuildContext.getProject().getName(); + alreadyBuilt.push(projectName); + } + } if (queue.length > 1) { // Do not log if only the root project is being built this.#log.info(`Processing ${queue.length} projects`); if (alreadyBuilt.length) { @@ -240,13 +261,9 @@ class ProjectBuilder { .join("\n ")}`); } } - - if (destPath && cleanDest) { - this.#log.info(`Cleaning target directory...`); - await rmrf(destPath); - } - const startTime = process.hrtime(); + const cleanupSigHooks = this._registerCleanupSigHooks(); try { + const startTime = process.hrtime(); const pWrites = []; for (const projectBuildContext of queue) { const project = projectBuildContext.getProject(); @@ -285,21 +302,26 @@ class ProjectBuilder { await Promise.all(pWrites); this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); } catch (err) { - this.#log.error(`Build failed in ${this._getElapsedTime(startTime)}`); + this.#log.error(`Build failed`); throw err; } finally { this._deregisterCleanupSigHooks(cleanupSigHooks); await this._executeCleanupTasks(); } + } - if (watch) { - const relevantProjects = queue.map((projectBuildContext) => { - return projectBuildContext.getProject(); - }); - const watchHandler = this._buildContext.initWatchHandler(relevantProjects, async () => { - await this.#update(projectBuildContexts, requestedProjects, fsTarget); - }); - return watchHandler; + async #updateBuild(projectBuildContexts, requestedProjects, fsTarget) { + const cleanupSigHooks = this._registerCleanupSigHooks(); + try { + const startTime = process.hrtime(); + await this.#update(projectBuildContexts, requestedProjects, fsTarget); + this.#log.info(`Update succeeded in ${this._getElapsedTime(startTime)}`); + } catch (err) { + this.#log.error(`Update failed`); + this.#log.error(err); + } finally { + this._deregisterCleanupSigHooks(cleanupSigHooks); + await this._executeCleanupTasks(); } } diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index b23a80dd2dd..ea64c1643a4 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -205,8 +205,9 @@ class TaskRunner { } const usingCache = supportsDifferentialUpdates && cacheInfo; - this._log.info( - `Executing task ${taskName} for project ${this._project.getName()}`); + this._log.verbose( + `Executing task ${taskName} for project ${this._project.getName()}` + + (usingCache ? ` (using differential update)` : "")); const workspace = createMonitor(this._project.getWorkspace()); const params = { workspace, @@ -220,9 +221,6 @@ class TaskRunner { params.dependencies = dependencies; } if (usingCache) { - this._log.info( - `Using differential update for task ${taskName} of project ${this._project.getName()}`); - // workspace = params.changedProjectResourcePaths = Array.from(cacheInfo.changedProjectResourcePaths); if (requiresDependencies) { params.changedDependencyResourcePaths = Array.from(cacheInfo.changedDependencyResourcePaths); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 72fa6f17a3c..8499938e981 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -129,7 +129,10 @@ export default class CacheManager { // Cache miss return null; } - throw err; + throw new Error(`Failed to read build manifest for ` + + `${projectId} / ${buildSignature}: ${err.message}`, { + cause: err, + }); } } @@ -183,7 +186,10 @@ export default class CacheManager { // Cache miss return null; } - throw err; + throw new Error(`Failed to read resource index cache for ` + + `${projectId} / ${buildSignature}: ${err.message}`, { + cause: err, + }); } } @@ -243,7 +249,10 @@ export default class CacheManager { // Cache miss return null; } - throw err; + throw new Error(`Failed to read stage metadata from cache for ` + + `${projectId} / ${buildSignature} / ${stageId} / ${stageSignature}: ${err.message}`, { + cause: err, + }); } } @@ -302,7 +311,10 @@ export default class CacheManager { // Cache miss return null; } - throw err; + throw new Error(`Failed to read task metadata from cache for ` + + `${projectId} / ${buildSignature} / ${taskName}: ${err.message}`, { + cause: err, + }); } } From ff270f2943af2ea0a7eee4d34e63d0d45f359016 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 14:43:09 +0100 Subject: [PATCH 056/223] refactor(logger): Log skipped projects/tasks info in grey color --- packages/logger/lib/writers/Console.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index 8243df7720c..61578b8e16f 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -334,7 +334,7 @@ class Console { } projectMetadata.buildSkipped = true; message = `${chalk.yellow(figures.tick)} ` + - `Skipping build of ${projectType} project ${chalk.bold(projectName)}`; + chalk.grey(`Skipping build of ${projectType} project ${chalk.bold(projectName)}`); // Update progress bar (if used) // All tasks of this projects are completed @@ -412,7 +412,7 @@ class Console { `Task execution already started`); } taskMetadata.executionEnded = true; - message = `${chalk.green(figures.tick)} Skipping task ${chalk.bold(taskName)}`; + message = chalk.yellow(figures.tick) + chalk.grey(` Skipping task ${chalk.bold(taskName)}`); // Update progress bar (if used) this._getProgressBar()?.increment(1); From bda035471ab6268f1849bbaa42360e0cdae24fa9 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 16:17:21 +0100 Subject: [PATCH 057/223] refactor(project): Fix cache update mechanism --- packages/project/lib/build/cache/BuildTaskCache.js | 2 +- packages/project/lib/build/cache/ProjectBuildCache.js | 11 ++++++++--- .../project/lib/build/cache/index/ResourceIndex.js | 9 +++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 70a9184e74f..7d87ccd168d 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -532,7 +532,7 @@ export default class BuildTaskCache { resourceIndex: resourceIndex.toCacheObject(), }); } else { - const rootResourceIndex = this.#resourceRequests.getMetadata(parentId); + const {resourceIndex: rootResourceIndex} = this.#resourceRequests.getMetadata(parentId); if (!rootResourceIndex) { throw new Error(`Missing root resource index for parent ID ${parentId}`); } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 9a774e990d3..0f54d0da5e2 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -182,6 +182,9 @@ export default class ProjectBuildCache { const deltaStageCache = await this.#findStageCache(stageName, [deltaInfo.originalSignature]); if (deltaStageCache) { log.verbose(`Using delta cached stage for task ${taskName} in project ${this.#project.getName()}`); + + // Store current project reader for later use in recordTaskResult + this.#currentProjectReader = this.#project.getReader(); return { previousStageCache: deltaStageCache, newSignature: deltaInfo.newSignature, @@ -190,10 +193,12 @@ export default class ProjectBuildCache { }; } } + } else { + // Store current project reader for later use in recordTaskResult + this.#currentProjectReader = this.#project.getReader(); + + return false; // Task needs to be executed } - // No cached stage found, store current project reader for later use in recordTaskResult - this.#currentProjectReader = this.#project.getReader(); - return false; // Task needs to be executed } /** diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index 6256958d655..7b914b2d644 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -205,6 +205,15 @@ export default class ResourceIndex { return await this.#tree.upsertResources(resources); } + /** + * Removes resources from the index. + * + * @param {Array} resourcePaths - Paths of resources to remove + */ + async removeResources(resourcePaths) { + return await this.#tree.removeResources(resourcePaths); + } + /** * Computes the signature hash for this resource index. * From 751034cefbc49a1dc8f24683b07c142e21a00e1a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 16:30:37 +0100 Subject: [PATCH 058/223] refactor(project): WatchHandler emit error event --- packages/project/lib/build/ProjectBuilder.js | 2 +- packages/project/lib/build/helpers/WatchHandler.js | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index a94d87a262e..68c911cd272 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -318,7 +318,7 @@ class ProjectBuilder { this.#log.info(`Update succeeded in ${this._getElapsedTime(startTime)}`); } catch (err) { this.#log.error(`Update failed`); - this.#log.error(err); + throw err; } finally { this._deregisterCleanupSigHooks(cleanupSigHooks); await this._executeCleanupTasks(); diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 726d8b48c55..4984507c4cb 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -94,8 +94,13 @@ class WatchHandler extends EventEmitter { this.#sourceChanges = new Map(); this.#updateInProgress = true; - await this.#handleResourceChanges(sourceChanges); - this.#updateInProgress = false; + try { + await this.#handleResourceChanges(sourceChanges); + } catch (err) { + this.emit("error", err); + } finally { + this.#updateInProgress = false; + } if (this.#sourceChanges.size > 0) { // New changes have occurred during processing, trigger queue again From a0b451c29681b0a32de1cc1c7c567f61f3366a39 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 16:31:00 +0100 Subject: [PATCH 059/223] refactor(server): Exit process on rebuild error --- packages/server/lib/server.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index e61906ffa6b..edbbd7ea5d4 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -181,6 +181,10 @@ export async function serve(graph, { resources.dependencies = newResources.dependencies; resources.all = newResources.all; }); + watchHandler.on("error", async (err) => { + log.error(`Watch handler error: ${err.message}`); + process.exit(1); + }); const middlewareManager = new MiddlewareManager({ graph, From 7454a51a62b45512fc9ba4c2037167f4b632b055 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 7 Jan 2026 21:12:23 +0100 Subject: [PATCH 060/223] refactor(project): Fix delta indices --- .../project/lib/build/cache/BuildTaskCache.js | 2 +- .../project/lib/build/cache/CacheManager.js | 5 +- .../lib/build/cache/ProjectBuildCache.js | 25 ++-- .../project/lib/build/cache/index/HashTree.js | 103 +++++++------- .../lib/build/cache/index/ResourceIndex.js | 14 +- packages/project/lib/build/cache/utils.js | 10 +- .../lib/specifications/types/ThemeLibrary.js | 6 +- .../test/lib/build/cache/index/HashTree.js | 128 ++++++++++++++++++ 8 files changed, 219 insertions(+), 74 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 7d87ccd168d..75f25717999 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -569,7 +569,7 @@ export default class BuildTaskCache { if (!registry) { throw new Error(`Missing tree registry for parent of node ID ${nodeId}`); } - const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex, registry); + const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex); resourceRequests.setMetadata(nodeId, { resourceIndex, diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 8499938e981..596dbcbfdf6 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -47,6 +47,7 @@ export default class CacheManager { #casDir; #manifestDir; #stageMetadataDir; + #taskMetadataDir; #indexDir; /** @@ -63,6 +64,7 @@ export default class CacheManager { this.#casDir = path.join(cacheDir, "cas"); this.#manifestDir = path.join(cacheDir, "buildManifests"); this.#stageMetadataDir = path.join(cacheDir, "stageMetadata"); + this.#taskMetadataDir = path.join(cacheDir, "taskMetadata"); this.#indexDir = path.join(cacheDir, "index"); } @@ -222,6 +224,7 @@ export default class CacheManager { */ #getStageMetadataPath(packageName, buildSignature, stageId, stageSignature) { const pkgDir = getPathFromPackageName(packageName); + stageId = stageId.replace("/", "_"); return path.join(this.#stageMetadataDir, pkgDir, buildSignature, stageId, `${stageSignature}.json`); } @@ -287,7 +290,7 @@ export default class CacheManager { */ #getTaskMetadataPath(packageName, buildSignature, taskName) { const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#stageMetadataDir, pkgDir, buildSignature, taskName, `metadata.json`); + return path.join(this.#taskMetadataDir, pkgDir, buildSignature, taskName, `metadata.json`); } /** diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 0f54d0da5e2..69930956b7b 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -45,6 +45,8 @@ export default class ProjectBuildCache { * @param {object} cacheManager - Cache manager instance for reading/writing cache data */ constructor(project, buildSignature, cacheManager) { + log.verbose( + `ProjectBuildCache for project ${project.getName()} uses build signature ${buildSignature}`); this.#project = project; this.#buildSignature = buildSignature; this.#cacheManager = cacheManager; @@ -140,18 +142,18 @@ export default class ProjectBuildCache { * 4. Returns whether the task needs to be executed * * @param {string} taskName - Name of the task to prepare - * @param {boolean} requiresDependencies - Whether the task requires dependency reader * @returns {Promise} True or object if task can use cache, false otherwise */ - async prepareTaskExecution(taskName, requiresDependencies) { + async prepareTaskExecution(taskName) { const stageName = this.#getStageNameForTask(taskName); const taskCache = this.#taskCache.get(taskName); // Switch project to new stage this.#project.useStage(stageName); - + log.verbose(`Preparing task execution for task ${taskName} in project ${this.#project.getName()}...`); if (taskCache) { let deltaInfo; if (this.#invalidatedTasks.has(taskName)) { + log.verbose(`Task cache for task ${taskName} has been invalidated, updating indices...`); const invalidationInfo = this.#invalidatedTasks.get(taskName); deltaInfo = await taskCache.updateIndices( @@ -181,7 +183,11 @@ export default class ProjectBuildCache { const deltaStageCache = await this.#findStageCache(stageName, [deltaInfo.originalSignature]); if (deltaStageCache) { - log.verbose(`Using delta cached stage for task ${taskName} in project ${this.#project.getName()}`); + log.verbose( + `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + + `with original signature ${deltaInfo.originalSignature} (now ${deltaInfo.newSignature}) ` + + `and ${deltaInfo.changedProjectResourcePaths.size} changed project resource paths and ` + + `${deltaInfo.changedDependencyResourcePaths.size} changed dependency resource paths.`); // Store current project reader for later use in recordTaskResult this.#currentProjectReader = this.#project.getReader(); @@ -194,11 +200,12 @@ export default class ProjectBuildCache { } } } else { - // Store current project reader for later use in recordTaskResult - this.#currentProjectReader = this.#project.getReader(); - - return false; // Task needs to be executed + log.verbose(`No task cache found`); } + // Store current project reader for later use in recordTaskResult + this.#currentProjectReader = this.#project.getReader(); + + return false; // Task needs to be executed } /** @@ -456,7 +463,7 @@ export default class ProjectBuildCache { * @returns {boolean} True if cache exists and is valid for this task */ isTaskCacheValid(taskName) { - return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName); + return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName) && !this.#requiresInitialBuild; } /** diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index fcb73b83232..d70a221817c 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -4,10 +4,11 @@ import {matchResourceMetadataStrict} from "../utils.js"; /** * @typedef {object} @ui5/project/build/cache/index/HashTree~ResourceMetadata - * @property {number} size - File size in bytes - * @property {number} lastModified - Last modification timestamp - * @property {number|undefined} inode - File inode identifier - * @property {string} integrity - Content hash + * @property {string} path Resource path using POSIX separators, prefixed with a slash (e.g. "/resources/file.js") + * @property {number} size File size in bytes + * @property {number} lastModified Last modification timestamp + * @property {number|undefined} inode File inode identifier + * @property {string} integrity Content hash */ /** @@ -219,30 +220,8 @@ export default class HashTree { _insertResourceWithSharing(resourcePath, resourceData) { const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); let current = this.root; - const pathToCopy = []; // Track path that needs copy-on-write - - // Phase 1: Navigate to find where we need to start copying - for (let i = 0; i < parts.length - 1; i++) { - const dirName = parts[i]; - - if (!current.children.has(dirName)) { - // New directory needed - we'll create from here - break; - } - - const existing = current.children.get(dirName); - if (existing.type !== "directory") { - throw new Error(`Path conflict: ${dirName} exists as resource but expected directory`); - } - - pathToCopy.push({parent: current, dirName, node: existing}); - current = existing; - } - - // Phase 2: Copy path from root down (copy-on-write) - // Only copy directories that will have their children modified - current = this.root; + // Navigate and copy-on-write for all directories in the path for (let i = 0; i < parts.length - 1; i++) { const dirName = parts[i]; @@ -251,17 +230,17 @@ export default class HashTree { const newDir = new TreeNode(dirName, "directory"); current.children.set(dirName, newDir); current = newDir; - } else if (i === parts.length - 2) { - // This is the parent directory that will get the new resource - // Copy it to avoid modifying shared structure + } else { + // Directory exists - need to copy it because we'll modify its children const existing = current.children.get(dirName); + if (existing.type !== "directory") { + throw new Error(`Path conflict: ${dirName} exists as resource but expected directory`); + } + + // Shallow copy to preserve copy-on-write semantics const copiedDir = this._shallowCopyDirectory(existing); current.children.set(dirName, copiedDir); current = copiedDir; - } else { - // Just traverse - don't copy intermediate directories - // They remain shared with the source tree (structural sharing) - current = current.children.get(dirName); } } @@ -958,30 +937,63 @@ export default class HashTree { * that were added compared to the base tree. * * @param {HashTree} rootTree - The base tree to compare against - * @returns {Array} Array of added resource nodes + * @returns {Array} + * Array of added resource metadata */ getAddedResources(rootTree) { const added = []; const traverse = (node, currentPath, implicitlyAdded = false) => { if (implicitlyAdded) { + // We're in a subtree that's entirely new - add all resources if (node.type === "resource") { - added.push(node); + added.push({ + path: currentPath, + integrity: node.integrity, + size: node.size, + lastModified: node.lastModified, + inode: node.inode + }); } } else { const baseNode = rootTree._findNode(currentPath); if (baseNode && baseNode === node) { - // Node exists in base tree and is the same (structural sharing) + // Node exists in base tree and is the same object (structural sharing) // Neither node nor children are added return; - } else { - // Node doesn't exist in base tree - it's added - if (node.type === "resource") { - added.push(node); - } else { - // Directory - all children are added - implicitlyAdded = true; + } else if (baseNode && node.type === "directory") { + // Directory exists in both trees but may have been shallow-copied + // Check children individually - only process children that differ + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + const baseChild = baseNode.children.get(name); + + if (!baseChild || baseChild !== child) { + // Child doesn't exist in base or is different - determine if added + if (!baseChild) { + // Entirely new - all descendants are added + traverse(child, childPath, true); + } else { + // Child was modified/replaced - recurse normally + traverse(child, childPath, false); + } + } + // If baseChild === child, skip it (shared) } + return; // Don't continue with normal traversal + } else if (!baseNode && node.type === "resource") { + // Resource doesn't exist in base tree - it's added + added.push({ + path: currentPath, + integrity: node.integrity, + size: node.size, + lastModified: node.lastModified, + inode: node.inode + }); + return; + } else if (!baseNode && node.type === "directory") { + // Directory doesn't exist in base tree - all children are added + implicitlyAdded = true; } } @@ -992,8 +1004,7 @@ export default class HashTree { } } }; - - traverse(this.root, ""); + traverse(this.root, "/"); return added; } } diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index 7b914b2d644..fc866d0b7a3 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -151,7 +151,7 @@ export default class ResourceIndex { return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); } - async deriveTreeWithIndex(resourceIndex) { + deriveTreeWithIndex(resourceIndex) { return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); } @@ -174,18 +174,10 @@ export default class ResourceIndex { * for resources that have been added in this index. * * @param {ResourceIndex} baseIndex - The base resource index to compare against + * @returns {Array<@ui5/project/build/cache/index/HashTree~ResourceMetadata>} */ getAddedResourceIndex(baseIndex) { - const addedResources = this.#tree.getAddedResources(baseIndex.getTree()); - return addedResources.map(((resource) => { - return { - path: resource.path, - integrity: resource.integrity, - size: resource.size, - lastModified: resource.lastModified, - inode: resource.inode, - }; - })); + return this.#tree.getAddedResources(baseIndex.getTree()); } /** diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 3925660e357..7bd41ac0b86 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -98,6 +98,15 @@ export async function matchResourceMetadataStrict(resource, cachedMetadata, inde return currentIntegrity === cachedMetadata.integrity; } + +/** + * Creates an index of resource metadata from an array of resources. + * + * @param {Array<@ui5/fs/Resource>} resources - Array of resources to index + * @param {boolean} [includeInode=false] - Whether to include inode information in the metadata + * @returns {Promise>} + * Array of resource metadata objects + */ export async function createResourceIndex(resources, includeInode = false) { return await Promise.all(resources.map(async (resource) => { const resourceMetadata = { @@ -105,7 +114,6 @@ export async function createResourceIndex(resources, includeInode = false) { integrity: await resource.getIntegrity(), lastModified: resource.getLastModified(), size: await resource.getSize(), - inode: await resource.getInode(), }; if (includeInode) { resourceMetadata.inode = resource.getInode(); diff --git a/packages/project/lib/specifications/types/ThemeLibrary.js b/packages/project/lib/specifications/types/ThemeLibrary.js index b9f352f0a6c..c9c00dc6cea 100644 --- a/packages/project/lib/specifications/types/ThemeLibrary.js +++ b/packages/project/lib/specifications/types/ThemeLibrary.js @@ -46,11 +46,7 @@ class ThemeLibrary extends Project { const sourcePath = this.getSourcePath(); if (sourceFilePath.startsWith(sourcePath)) { const relSourceFilePath = fsPath.relative(sourcePath, sourceFilePath); - let virBasePath = "/resources/"; - if (!this._isSourceNamespaced) { - virBasePath += `${this._namespace}/`; - } - return `${virBasePath}${relSourceFilePath}`; + return `/resources/${relSourceFilePath}`; } throw new Error( diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index 8b524b50fed..47d8c025e13 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -551,3 +551,131 @@ test("deriveTree - changes propagate to derived trees (shared view)", async (t) // This is the intended behavior: derived trees are views, not snapshots // Tree2 filters which resources it exposes, but underlying data is shared }); + +// ============================================================================ +// getAddedResources Tests +// ============================================================================ + +test("getAddedResources - returns empty array when no resources added", (t) => { + const baseTree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ]); + + const derivedTree = baseTree.deriveTree([]); + + const added = derivedTree.getAddedResources(baseTree); + + t.deepEqual(added, [], "Should return empty array when no resources added"); +}); + +test("getAddedResources - returns added resources from derived tree", (t) => { + const baseTree = new HashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ]); + + const derivedTree = baseTree.deriveTree([ + {path: "c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, + {path: "d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 2, "Should return 2 added resources"); + t.deepEqual(added, [ + {path: "/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, + {path: "/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + ], "Should return correct added resources with metadata"); +}); + +test("getAddedResources - handles nested directory additions", (t) => { + const baseTree = new HashTree([ + {path: "root/a.js", integrity: "hash-a"} + ]); + + const derivedTree = baseTree.deriveTree([ + {path: "root/nested/b.js", integrity: "hash-b", size: 100, lastModified: 1000, inode: 1}, + {path: "root/nested/c.js", integrity: "hash-c", size: 200, lastModified: 2000, inode: 2} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 2, "Should return 2 added resources"); + t.true(added.some((r) => r.path === "/root/nested/b.js"), "Should include nested b.js"); + t.true(added.some((r) => r.path === "/root/nested/c.js"), "Should include nested c.js"); +}); + +test("getAddedResources - handles new directory with multiple resources", (t) => { + const baseTree = new HashTree([ + {path: "src/a.js", integrity: "hash-a"} + ]); + + const derivedTree = baseTree.deriveTree([ + {path: "lib/b.js", integrity: "hash-b", size: 100, lastModified: 1000, inode: 1}, + {path: "lib/c.js", integrity: "hash-c", size: 200, lastModified: 2000, inode: 2}, + {path: "lib/nested/d.js", integrity: "hash-d", size: 300, lastModified: 3000, inode: 3} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 3, "Should return 3 added resources"); + t.true(added.some((r) => r.path === "/lib/b.js"), "Should include lib/b.js"); + t.true(added.some((r) => r.path === "/lib/c.js"), "Should include lib/c.js"); + t.true(added.some((r) => r.path === "/lib/nested/d.js"), "Should include nested resource"); +}); + +test("getAddedResources - preserves metadata for added resources", (t) => { + const baseTree = new HashTree([ + {path: "a.js", integrity: "hash-a"} + ]); + + const derivedTree = baseTree.deriveTree([ + {path: "b.js", integrity: "hash-b", size: 12345, lastModified: 9999, inode: 7777} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 1, "Should return 1 added resource"); + t.is(added[0].path, "/b.js", "Should have correct path"); + t.is(added[0].integrity, "hash-b", "Should preserve integrity"); + t.is(added[0].size, 12345, "Should preserve size"); + t.is(added[0].lastModified, 9999, "Should preserve lastModified"); + t.is(added[0].inode, 7777, "Should preserve inode"); +}); + +test("getAddedResources - handles mixed shared and added resources", (t) => { + const baseTree = new HashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ]); + + const derivedTree = baseTree.deriveTree([ + {path: "shared/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, + {path: "unique/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 2, "Should return 2 added resources"); + t.true(added.some((r) => r.path === "/shared/c.js"), "Should include c.js in shared dir"); + t.true(added.some((r) => r.path === "/unique/d.js"), "Should include d.js in unique dir"); + t.false(added.some((r) => r.path === "/shared/a.js"), "Should not include shared a.js"); + t.false(added.some((r) => r.path === "/shared/b.js"), "Should not include shared b.js"); +}); + +test("getAddedResources - handles deeply nested additions", (t) => { + const baseTree = new HashTree([ + {path: "a.js", integrity: "hash-a"} + ]); + + const derivedTree = baseTree.deriveTree([ + {path: "dir1/dir2/dir3/dir4/deep.js", integrity: "hash-deep", size: 100, lastModified: 1000, inode: 1} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 1, "Should return 1 added resource"); + t.is(added[0].path, "/dir1/dir2/dir3/dir4/deep.js", "Should have correct deeply nested path"); + t.is(added[0].integrity, "hash-deep", "Should preserve integrity"); +}); From 91dbc602ae1002197d026282e53d4843775a7878 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 8 Jan 2026 14:44:45 +0100 Subject: [PATCH 061/223] refactor(logger): Support differential update task logging --- packages/logger/lib/loggers/ProjectBuild.js | 6 ++++-- packages/logger/lib/writers/Console.js | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/logger/lib/loggers/ProjectBuild.js b/packages/logger/lib/loggers/ProjectBuild.js index 61c81115963..57856e211fe 100644 --- a/packages/logger/lib/loggers/ProjectBuild.js +++ b/packages/logger/lib/loggers/ProjectBuild.js @@ -48,7 +48,7 @@ class ProjectBuild extends Logger { }); } - startTask(taskName) { + startTask(taskName, isDifferentialBuild) { if (!this.#tasksToRun || !this.#tasksToRun.includes(taskName)) { throw new Error(`loggers/ProjectBuild#startTask: Unknown task ${taskName}`); } @@ -59,6 +59,7 @@ class ProjectBuild extends Logger { projectType: this.#projectType, taskName, status: "task-start", + isDifferentialBuild, }); if (!hasListeners) { @@ -66,7 +67,7 @@ class ProjectBuild extends Logger { } } - endTask(taskName) { + endTask(taskName, isDifferentialBuild) { if (!this.#tasksToRun || !this.#tasksToRun.includes(taskName)) { throw new Error(`loggers/ProjectBuild#endTask: Unknown task ${taskName}`); } @@ -77,6 +78,7 @@ class ProjectBuild extends Logger { projectType: this.#projectType, taskName, status: "task-end", + isDifferentialBuild, }); if (!hasListeners) { diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index 61578b8e16f..846701cf807 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -349,7 +349,7 @@ class Console { this.#writeMessage(level, `${chalk.grey(buildIndex)}: ${message}`); } - #handleProjectBuildStatusEvent({level, projectName, projectType, taskName, status}) { + #handleProjectBuildStatusEvent({level, projectName, projectType, taskName, status, isDifferentialBuild}) { const {projectTasks} = this.#getProjectMetadata(projectName); const taskMetadata = projectTasks.get(taskName); if (!taskMetadata) { @@ -382,7 +382,8 @@ class Console { `for project ${projectName}, task ${taskName}`); } taskMetadata.executionStarted = true; - message = `${chalk.blue(figures.pointerSmall)} Running task ${chalk.bold(taskName)}...`; + message = (isDifferentialBuild ? chalk.grey(figures.lozengeOutline) : chalk.blue(figures.pointerSmall)) + + ` Running task ${chalk.bold(taskName)}...`; break; case "task-end": if (taskMetadata.executionEnded) { From e1b57e2eab6810a807f93efdf547ee3597756197 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 8 Jan 2026 14:45:02 +0100 Subject: [PATCH 062/223] refactor(project): Provide differential update flag to logger --- packages/project/lib/build/TaskRunner.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index ea64c1643a4..2c3b934bd4b 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -198,16 +198,12 @@ class TaskRunner { options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); // TODO: Apply cache and stage handling for custom tasks as well - const cacheInfo = await this._buildCache.prepareTaskExecution(taskName, requiresDependencies); + const cacheInfo = await this._buildCache.prepareTaskExecution(taskName); if (cacheInfo === true) { this._log.skipTask(taskName); return; } const usingCache = supportsDifferentialUpdates && cacheInfo; - - this._log.verbose( - `Executing task ${taskName} for project ${this._project.getName()}` + - (usingCache ? ` (using differential update)` : "")); const workspace = createMonitor(this._project.getWorkspace()); const params = { workspace, @@ -230,7 +226,7 @@ class TaskRunner { const {task} = await this._taskRepository.getTask(taskName); taskFunction = task; } - this._log.startTask(taskName); + this._log.startTask(taskName, usingCache); this._taskStart = performance.now(); await taskFunction(params); if (this._log.isLevelEnabled("perf")) { From a113712ca6e88b3ca8c4d72779387fce5ed6b66f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 8 Jan 2026 14:45:38 +0100 Subject: [PATCH 063/223] refactor(server): Log error stack on build error --- packages/server/lib/server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index edbbd7ea5d4..54e1acd4aac 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -183,6 +183,7 @@ export async function serve(graph, { }); watchHandler.on("error", async (err) => { log.error(`Watch handler error: ${err.message}`); + log.verbose(err.stack); process.exit(1); }); From 286e54284c558b6592d5a217ca46c2418956dfb5 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 8 Jan 2026 20:59:40 +0100 Subject: [PATCH 064/223] refactor(project): Add chokidar --- package-lock.json | 1 + packages/project/lib/build/ProjectBuilder.js | 13 +++- .../project/lib/build/helpers/BuildContext.js | 4 +- .../project/lib/build/helpers/WatchHandler.js | 74 ++++++++++--------- packages/project/package.json | 1 + 5 files changed, 52 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 776e14ba0e2..a43aeae5ee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16035,6 +16035,7 @@ "ajv-errors": "^3.0.0", "cacache": "^20.0.3", "chalk": "^5.6.2", + "chokidar": "^3.6.0", "escape-string-regexp": "^5.0.0", "globby": "^14.1.0", "graceful-fs": "^4.2.11", diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 68c911cd272..774b31759b3 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -208,16 +208,23 @@ class ProjectBuilder { await rmrf(destPath); } - await this.#build(queue, projectBuildContexts, requestedProjects, fsTarget); - + let pWatchInit; if (watch) { const relevantProjects = queue.map((projectBuildContext) => { return projectBuildContext.getProject(); }); - return this._buildContext.initWatchHandler(relevantProjects, async () => { + // Start watching already while the initial build is running + pWatchInit = this._buildContext.initWatchHandler(relevantProjects, async () => { await this.#updateBuild(projectBuildContexts, requestedProjects, fsTarget); }); } + + const [, watchHandler] = await Promise.all([ + this.#build(queue, projectBuildContexts, requestedProjects, fsTarget), + pWatchInit + ]); + watchHandler.setReady(); + return watchHandler; } async #build(queue, projectBuildContexts, requestedProjects, fsTarget) { diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index bab2e2f0282..5b93cce062a 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -110,9 +110,9 @@ class BuildContext { return projectBuildContext; } - initWatchHandler(projects, updateBuildResult) { + async initWatchHandler(projects, updateBuildResult) { const watchHandler = new WatchHandler(this, updateBuildResult); - watchHandler.watch(projects); + await watchHandler.watch(projects); this.#watchHandler = watchHandler; return watchHandler; } diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 4984507c4cb..f67cfc9cabe 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -1,6 +1,5 @@ import EventEmitter from "node:events"; -import path from "node:path"; -import {watch} from "node:fs/promises"; +import chokidar from "chokidar"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:helpers:WatchHandler"); @@ -13,8 +12,9 @@ const log = getLogger("build:helpers:WatchHandler"); class WatchHandler extends EventEmitter { #buildContext; #updateBuildResult; - #abortControllers = []; + #closeCallbacks = []; #sourceChanges = new Map(); + #ready = false; #updateInProgress = false; #fileChangeHandlerTimeout; @@ -24,45 +24,47 @@ class WatchHandler extends EventEmitter { this.#updateBuildResult = updateBuildResult; } - watch(projects) { + setReady() { + this.#ready = true; + this.#processQueue(); + } + + async watch(projects) { + const readyPromises = []; for (const project of projects) { const paths = project.getSourcePaths(); log.verbose(`Watching source paths: ${paths.join(", ")}`); - for (const sourceDir of paths) { - const ac = new AbortController(); - const watcher = watch(sourceDir, { - persistent: true, - recursive: true, - signal: ac.signal, - }); - - this.#abortControllers.push(ac); - this.#handleWatchEvents(watcher, sourceDir, project); // Do not await as this would block the loop - } + const watcher = chokidar.watch(paths, { + ignoreInitial: true, + }); + this.#closeCallbacks.push(async () => { + await watcher.close(); + }); + watcher.on("all", (event, filePath) => { + this.#handleWatchEvents(event, filePath, project); + }); + const {promise, resolve} = Promise.withResolvers(); + readyPromises.push(promise); + watcher.on("ready", () => { + resolve(); + }); + watcher.on("error", (err) => { + this.emit("error", err); + }); } + return await Promise.all(readyPromises); } - stop() { - for (const ac of this.#abortControllers) { - ac.abort(); + async stop() { + for (const cb of this.#closeCallbacks) { + await cb(); } } - async #handleWatchEvents(watcher, basePath, project) { - try { - for await (const {eventType, filename} of watcher) { - log.verbose(`File changed: ${eventType} ${filename}`); - if (filename) { - await this.#fileChanged(project, path.join(basePath, filename.toString())); - } - } - } catch (err) { - if (err.name === "AbortError") { - return; - } - throw err; - } + async #handleWatchEvents(eventType, filePath, project) { + log.verbose(`File changed: ${eventType} ${filePath}`); + await this.#fileChanged(project, filePath); } #fileChanged(project, filePath) { @@ -73,11 +75,11 @@ class WatchHandler extends EventEmitter { } this.#sourceChanges.get(project).add(resourcePath); - this.#queueHandleResourceChanges(); + this.#processQueue(); } - #queueHandleResourceChanges() { - if (this.#updateInProgress) { + #processQueue() { + if (!this.#ready || this.#updateInProgress) { // Prevent concurrent updates return; } @@ -104,7 +106,7 @@ class WatchHandler extends EventEmitter { if (this.#sourceChanges.size > 0) { // New changes have occurred during processing, trigger queue again - this.#queueHandleResourceChanges(); + this.#processQueue(); } }, 100); } diff --git a/packages/project/package.json b/packages/project/package.json index d45a980dc57..cfc354986b2 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -63,6 +63,7 @@ "ajv-errors": "^3.0.0", "cacache": "^20.0.3", "chalk": "^5.6.2", + "chokidar": "^3.6.0", "escape-string-regexp": "^5.0.0", "globby": "^14.1.0", "graceful-fs": "^4.2.11", From 1e386a5b030b46b0dfd3351b5c2f63b74567ea9a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 8 Jan 2026 21:45:24 +0100 Subject: [PATCH 065/223] refactor(project): Limit build signature to project name and config --- .../build/helpers/calculateBuildSignature.js | 83 +------------------ 1 file changed, 3 insertions(+), 80 deletions(-) diff --git a/packages/project/lib/build/helpers/calculateBuildSignature.js b/packages/project/lib/build/helpers/calculateBuildSignature.js index 6ca04986bf0..684ea0c4d17 100644 --- a/packages/project/lib/build/helpers/calculateBuildSignature.js +++ b/packages/project/lib/build/helpers/calculateBuildSignature.js @@ -1,8 +1,7 @@ -import {createRequire} from "node:module"; import crypto from "node:crypto"; // Using CommonsJS require since JSON module imports are still experimental -const require = createRequire(import.meta.url); +const BUILD_CACHE_VERSION = "0"; /** * The build signature is calculated based on the **build configuration and environment** of a project. @@ -17,86 +16,10 @@ const require = createRequire(import.meta.url); * versions of ui5-builder and ui5-fs) */ export default async function calculateBuildSignature(project, graph, buildConfig, taskRepository) { - const depInfo = collectDepInfo(graph, project); - const lockfileHash = await getLockfileHash(project); - const {builderVersion, fsVersion: builderFsVersion} = await taskRepository.getVersions(); - const projectVersion = await getVersion("@ui5/project"); - const fsVersion = await getVersion("@ui5/fs"); - - const key = project.getName() + project.getVersion() + - JSON.stringify(buildConfig) + JSON.stringify(depInfo) + - builderVersion + projectVersion + fsVersion + builderFsVersion + - lockfileHash; + const key = BUILD_CACHE_VERSION + project.getName() + + JSON.stringify(buildConfig); // Create a hash for all metadata const hash = crypto.createHash("sha256").update(key).digest("hex"); return hash; } - -async function getVersion(pkg) { - return require(`${pkg}/package.json`).version; -} - -async function getLockfileHash(project) { - const rootReader = project.getRootReader({useGitIgnore: false}); - const lockfiles = await Promise.all([ - // TODO: Search upward for lockfiles in parent directories? - // npm - await rootReader.byPath("/package-lock.json"), - await rootReader.byPath("/npm-shrinkwrap.json"), - // Yarn - await rootReader.byPath("/yarn.lock"), - // pnpm - await rootReader.byPath("/pnpm-lock.yaml"), - ]); - let hash = ""; - for (const lockfile of lockfiles) { - if (lockfile) { - const content = await lockfile.getBuffer(); - hash += crypto.createHash("sha256").update(content).digest("hex"); - } - } - return hash; -} - -function collectDepInfo(graph, project) { - let projects = []; - for (const depName of graph.getTransitiveDependencies(project.getName())) { - const dep = graph.getProject(depName); - projects.push({ - name: dep.getName(), - version: dep.getVersion() - }); - } - projects = projects.sort((a, b) => { - return a.name.localeCompare(b.name); - }); - - // Collect relevant extensions - let extensions = []; - if (graph.getRoot() === project) { - // Custom middleware is only relevant for root project - project.getCustomMiddleware().forEach((middlewareDef) => { - const extension = graph.getExtension(middlewareDef.name); - if (extension) { - extensions.push({ - name: extension.getName(), - version: extension.getVersion() - }); - } - }); - } - project.getCustomTasks().forEach((taskDef) => { - const extension = graph.getExtension(taskDef.name); - if (extension) { - extensions.push({ - name: extension.getName(), - version: extension.getVersion() - }); - } - }); - extensions = extensions.sort((a, b) => { - return a.name.localeCompare(b.name); - }); - return {projects, extensions}; -} From f559ee4cb874d22d9935307da37f619cad014925 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 8 Jan 2026 21:52:05 +0100 Subject: [PATCH 066/223] refactor(project): Improve ResourceRequestGraph handling --- .../project/lib/build/cache/BuildTaskCache.js | 22 +++- .../lib/build/cache/ProjectBuildCache.js | 1 + .../lib/build/cache/ResourceRequestGraph.js | 116 ++++-------------- .../lib/build/cache/ResourceRequestGraph.js | 36 +++--- .../test/lib/build/cache/index/HashTree.js | 4 - 5 files changed, 60 insertions(+), 119 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 75f25717999..290b7bf2d89 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -282,6 +282,7 @@ export default class BuildTaskCache { requests.push(new Request("dep-patterns", patterns)); } } + // Try to find an existing request set that we can reuse let setId = this.#resourceRequests.findExactMatch(requests); let resourceIndex; if (setId) { @@ -301,8 +302,14 @@ export default class BuildTaskCache { const addedRequests = requestSet.getAddedRequests(); const resourcesToAdd = await this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); + if (!resourcesToAdd.length) { + throw new Error(`Unexpected empty added resources for request set ID ${setId} ` + + `of task '${this.#taskName}' of project '${this.#projectName}'`); + } + log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + + `created derived resource index for request set ID ${setId} ` + + `based on parent ID ${parentId} with ${resourcesToAdd.length} additional resources`); resourceIndex = await parentResourceIndex.deriveTree(resourcesToAdd); - // await newIndex.add(resourcesToAdd); } else { const resourcesRead = await this.#getResourcesForRequests(requests, projectReader, dependencyReader); @@ -438,7 +445,7 @@ export default class BuildTaskCache { * @param {Request[]|Array<{type: string, value: string|string[]}>} resourceRequests - Resource requests to process * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for project resources * @param {module:@ui5/fs.AbstractReader} dependencyReder - Reader for dependency resources - * @returns {Promise>} Iterator of retrieved resources + * @returns {Promise>} Iterator of retrieved resources * @throws {Error} If an unknown request type is encountered */ async #getResourcesForRequests(resourceRequests, projectReader, dependencyReder) { @@ -477,7 +484,7 @@ export default class BuildTaskCache { throw new Error(`Unknown request type: ${type}`); } } - return resourcesMap.values(); + return Array.from(resourcesMap.values()); } /** @@ -519,6 +526,10 @@ export default class BuildTaskCache { * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph */ toCacheObject() { + if (!this.#resourceRequests) { + throw new Error("BuildTaskCache#toCacheObject: Resource requests not initialized for task " + + `'${this.#taskName}' of project '${this.#projectName}'`); + } const rootIndices = []; const deltaIndices = []; for (const {nodeId, parentId} of this.#resourceRequests.traverseByDepth()) { @@ -536,6 +547,8 @@ export default class BuildTaskCache { if (!rootResourceIndex) { throw new Error(`Missing root resource index for parent ID ${parentId}`); } + // Store the metadata for all added resources. Note: Those resources might not be available + // in the current tree. In that case we store an empty array. const addedResourceIndex = resourceIndex.getAddedResourceIndex(rootResourceIndex); deltaIndices.push({ nodeId, @@ -567,7 +580,8 @@ export default class BuildTaskCache { const {resourceIndex: parentResourceIndex} = resourceRequests.getMetadata(node.getParentId()); const registry = registries.get(node.getParentId()); if (!registry) { - throw new Error(`Missing tree registry for parent of node ID ${nodeId}`); + throw new Error(`Missing tree registry for parent of node ID ${nodeId} of task ` + + `'${this.#taskName}' of project '${this.#projectName}'`); } const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 69930956b7b..31a86938de1 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -235,6 +235,7 @@ export default class ProjectBuildCache { const stageMetadata = await this.#cacheManager.readStageCache( this.#project.getId(), this.#buildSignature, stageName, stageSignature); if (stageMetadata) { + log.verbose(`Found cached stage with signature ${stageSignature}`); const reader = await this.#createReaderForStageCache( stageName, stageSignature, stageMetadata.resourceMetadata); return { diff --git a/packages/project/lib/build/cache/ResourceRequestGraph.js b/packages/project/lib/build/cache/ResourceRequestGraph.js index aac512d24b7..65366ac3885 100644 --- a/packages/project/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/lib/build/cache/ResourceRequestGraph.js @@ -187,55 +187,6 @@ export default class ResourceRequestGraph { return Array.from(this.nodes.keys()); } - /** - * Find the best parent for a new request set using greedy selection - * - * @param {Request[]} requestSet - Array of Request objects - * @returns {{parentId: number, deltaSize: number}|null} Parent info or null if no suitable parent - */ - findBestParent(requestSet) { - if (this.nodes.size === 0) { - return null; - } - - const requestKeys = new Set(requestSet.map((r) => r.toKey())); - let bestParent = null; - let smallestDelta = Infinity; - - // Compare against all existing nodes - for (const [nodeId, node] of this.nodes) { - const nodeSet = node.getMaterializedSet(this); - - // Calculate how many new requests would need to be added - const delta = this._calculateDelta(requestKeys, nodeSet); - - // We want the parent that minimizes the delta (maximum overlap) - if (delta < smallestDelta) { - smallestDelta = delta; - bestParent = nodeId; - } - } - - return bestParent !== null ? {parentId: bestParent, deltaSize: smallestDelta} : null; - } - - /** - * Calculate the size of the delta (requests in newSet not in existingSet) - * - * @param {Set} newSetKeys - Set of request keys - * @param {Set} existingSetKeys - Set of existing request keys - * @returns {number} Number of requests in newSet not in existingSet - */ - _calculateDelta(newSetKeys, existingSetKeys) { - let deltaCount = 0; - for (const key of newSetKeys) { - if (!existingSetKeys.has(key)) { - deltaCount++; - } - } - return deltaCount; - } - /** * Calculate which requests need to be added (delta) * @@ -245,15 +196,9 @@ export default class ResourceRequestGraph { */ _calculateAddedRequests(newRequestSet, parentSet) { const newKeys = new Set(newRequestSet.map((r) => r.toKey())); - const addedKeys = []; + const addedKeys = newKeys.difference(parentSet); - for (const key of newKeys) { - if (!parentSet.has(key)) { - addedKeys.push(key); - } - } - - return addedKeys.map((key) => Request.fromKey(key)); + return Array.from(addedKeys).map((key) => Request.fromKey(key)); } /** @@ -267,9 +212,9 @@ export default class ResourceRequestGraph { const nodeId = this.nextId++; // Find best parent - const parentInfo = this.findBestParent(requests); + const parentId = this.findBestParent(requests); - if (parentInfo === null) { + if (parentId === null) { // No existing nodes, or no suitable parent - create root node const node = new RequestSetNode(nodeId, null, requests, metadata); this.nodes.set(nodeId, node); @@ -277,43 +222,46 @@ export default class ResourceRequestGraph { } // Create node with delta from best parent - const parentNode = this.getNode(parentInfo.parentId); + const parentNode = this.getNode(parentId); const parentSet = parentNode.getMaterializedSet(this); const addedRequests = this._calculateAddedRequests(requests, parentSet); - const node = new RequestSetNode(nodeId, parentInfo.parentId, addedRequests, metadata); + const node = new RequestSetNode(nodeId, parentId, addedRequests, metadata); this.nodes.set(nodeId, node); return nodeId; } /** - * Find the best matching node for a query request set - * Returns the node ID where the node's set is a subset of the query - * and is maximal (largest subset match) + * Find the best parent for a new request set. That is, the largest subset of the new request set. * - * @param {Request[]} queryRequests - Array of Request objects to match - * @returns {number|null} Node ID of best match, or null if no match found + * @param {Request[]} requestSet - Array of Request objects + * @returns {{parentId: number, deltaSize: number}|null} Parent info or null if no suitable parent */ - findBestMatch(queryRequests) { - const queryKeys = new Set(queryRequests.map((r) => r.toKey())); + findBestParent(requestSet) { + if (this.nodes.size === 0) { + return null; + } - let bestMatch = null; - let bestMatchSize = -1; + const queryKeys = new Set(requestSet.map((r) => r.toKey())); + let bestParent = null; + let greatestSubset = -1; + // Compare against all existing nodes for (const [nodeId, node] of this.nodes) { const nodeSet = node.getMaterializedSet(this); // Check if nodeSet is a subset of queryKeys - const isSubset = this._isSubset(nodeSet, queryKeys); + const isSubset = nodeSet.isSubsetOf(queryKeys); - if (isSubset && nodeSet.size > bestMatchSize) { - bestMatch = nodeId; - bestMatchSize = nodeSet.size; + // We want the parent the greatest overlap + if (isSubset && nodeSet.size > greatestSubset) { + bestParent = nodeId; + greatestSubset = nodeSet.size; } } - return bestMatch; + return bestParent; } /** @@ -338,7 +286,7 @@ export default class ResourceRequestGraph { } // Check if sets are identical (same size + subset = equality) - if (this._isSubset(nodeSet, queryKeys)) { + if (nodeSet.isSubsetOf(queryKeys)) { return nodeId; } } @@ -346,22 +294,6 @@ export default class ResourceRequestGraph { return null; } - /** - * Check if setA is a subset of setB - * - * @param {Set} setA - First set - * @param {Set} setB - Second set - * @returns {boolean} True if setA is a subset of setB - */ - _isSubset(setA, setB) { - for (const item of setA) { - if (!setB.has(item)) { - return false; - } - } - return true; - } - /** * Get metadata associated with a node * diff --git a/packages/project/test/lib/build/cache/ResourceRequestGraph.js b/packages/project/test/lib/build/cache/ResourceRequestGraph.js index 2a410cc7567..b705c96625c 100644 --- a/packages/project/test/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/test/lib/build/cache/ResourceRequestGraph.js @@ -155,18 +155,17 @@ test("ResourceRequestGraph: Add request set with parent relationship", (t) => { t.true(node2Data.addedRequests.has("path:c.js")); }); -test("ResourceRequestGraph: Add request set with no overlap creates parent", (t) => { +test("ResourceRequestGraph: Add request set with no overlap", (t) => { const graph = new ResourceRequestGraph(); const set1 = [new Request("path", "a.js")]; - const node1 = graph.addRequestSet(set1); + graph.addRequestSet(set1); const set2 = [new Request("path", "x.js")]; const node2 = graph.addRequestSet(set2); const node2Data = graph.getNode(node2); - // Even with no overlap, greedy algorithm will select best parent - t.is(node2Data.parent, node1); + t.falsy(node2Data.parent); t.is(node2Data.addedRequests.size, 1); t.true(node2Data.addedRequests.has("path:x.js")); }); @@ -218,7 +217,7 @@ test("ResourceRequestGraph: getAddedRequests returns only delta", (t) => { t.is(added[0].toKey(), "path:c.js"); }); -test("ResourceRequestGraph: findBestMatch returns node with largest subset", (t) => { +test("ResourceRequestGraph: findBestParent returns node with largest subset", (t) => { const graph = new ResourceRequestGraph(); // Add first request set @@ -243,13 +242,13 @@ test("ResourceRequestGraph: findBestMatch returns node with largest subset", (t) new Request("path", "c.js"), new Request("path", "x.js") ]; - const match = graph.findBestMatch(query); + const match = graph.findBestParent(query); // Should return node2 (largest subset: 3 items) t.is(match, node2); }); -test("ResourceRequestGraph: findBestMatch returns null when no subset found", (t) => { +test("ResourceRequestGraph: findBestParent returns null when no subset found", (t) => { const graph = new ResourceRequestGraph(); const set1 = [ @@ -263,7 +262,7 @@ test("ResourceRequestGraph: findBestMatch returns null when no subset found", (t new Request("path", "x.js"), new Request("path", "y.js") ]; - const match = graph.findBestMatch(query); + const match = graph.findBestParent(query); t.is(match, null); }); @@ -416,7 +415,7 @@ test("ResourceRequestGraph: getStats returns correct statistics", (t) => { t.is(stats.compressionRatio, 0.6); // 3 stored / 5 total }); -test("ResourceRequestGraph: toMetadataObject exports graph structure", (t) => { +test("ResourceRequestGraph: toCacheObject exports graph structure", (t) => { const graph = new ResourceRequestGraph(); const set1 = [new Request("path", "a.js")]; @@ -428,7 +427,7 @@ test("ResourceRequestGraph: toMetadataObject exports graph structure", (t) => { ]; const node2 = graph.addRequestSet(set2); - const exported = graph.toMetadataObject(); + const exported = graph.toCacheObject(); t.is(exported.nodes.length, 2); t.is(exported.nextId, 3); @@ -444,7 +443,7 @@ test("ResourceRequestGraph: toMetadataObject exports graph structure", (t) => { t.deepEqual(exportedNode2.addedRequests, ["path:b.js"]); }); -test("ResourceRequestGraph: fromMetadataObject reconstructs graph", (t) => { +test("ResourceRequestGraph: fromCacheObject reconstructs graph", (t) => { const graph1 = new ResourceRequestGraph(); const set1 = [new Request("path", "a.js")]; @@ -457,8 +456,8 @@ test("ResourceRequestGraph: fromMetadataObject reconstructs graph", (t) => { const node2 = graph1.addRequestSet(set2); // Export and reconstruct - const exported = graph1.toMetadataObject(); - const graph2 = ResourceRequestGraph.fromMetadataObject(exported); + const exported = graph1.toCacheObject(); + const graph2 = ResourceRequestGraph.fromCacheObject(exported); // Verify reconstruction t.is(graph2.nodes.size, 2); @@ -542,15 +541,15 @@ test("ResourceRequestGraph: findBestParent chooses optimal parent", (t) => { // Create two potential parents const set1 = [ - new Request("path", "a.js"), - new Request("path", "b.js") + new Request("path", "x.js"), + new Request("path", "y.js") ]; graph.addRequestSet(set1); const set2 = [ new Request("path", "x.js"), new Request("path", "y.js"), - new Request("path", "z.js") + new Request("path", "z.js"), ]; const node2 = graph.addRequestSet(set2); @@ -607,7 +606,7 @@ test("ResourceRequestGraph: Caching works correctly", (t) => { t.deepEqual(Array.from(materialized1).sort(), Array.from(materialized2).sort()); }); -test("ResourceRequestGraph: Usage example from documentation", (t) => { +test("ResourceRequestGraph: Integration", (t) => { // Create graph const graph = new ResourceRequestGraph(); @@ -637,9 +636,8 @@ test("ResourceRequestGraph: Usage example from documentation", (t) => { const query = [ new Request("path", "a.js"), new Request("path", "b.js"), - new Request("path", "x.js") ]; - const match = graph.findBestMatch(query); + const match = graph.findExactMatch(query); t.is(match, node1); // Get metadata diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index 47d8c025e13..3d9711962a0 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -452,10 +452,6 @@ test("removeResources - remove from nested directory", async (t) => { t.truthy(tree.hasPath("dir1/c.js"), "Should still have dir1/c.js"); }); -// ============================================================================ -// Critical Flaw Tests -// ============================================================================ - test("deriveTree - copies only modified directories (copy-on-write)", (t) => { const tree1 = new HashTree([ {path: "shared/a.js", integrity: "hash-a"}, From dfbdcc43b6dae335780d9c8ddb38d4fdd00b4418 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 8 Jan 2026 16:35:47 +0100 Subject: [PATCH 067/223] refactor(server): Remove obsolete code from serveResources middleware --- .../lib/middleware/MiddlewareManager.js | 1 + .../server/lib/middleware/serveResources.js | 114 ++---------------- 2 files changed, 13 insertions(+), 102 deletions(-) diff --git a/packages/server/lib/middleware/MiddlewareManager.js b/packages/server/lib/middleware/MiddlewareManager.js index a06a2475300..475dbec2420 100644 --- a/packages/server/lib/middleware/MiddlewareManager.js +++ b/packages/server/lib/middleware/MiddlewareManager.js @@ -218,6 +218,7 @@ class MiddlewareManager { }); await this.addMiddleware("serveResources"); await this.addMiddleware("testRunner"); + // TODO: Allow to still reference 'serveThemes' middleware in custom middleware // await this.addMiddleware("serveThemes"); await this.addMiddleware("versionInfo", { mountPath: "/resources/sap-ui-version.json" diff --git a/packages/server/lib/middleware/serveResources.js b/packages/server/lib/middleware/serveResources.js index 3e6c1d0ac63..74528972ada 100644 --- a/packages/server/lib/middleware/serveResources.js +++ b/packages/server/lib/middleware/serveResources.js @@ -1,15 +1,5 @@ -import {getLogger} from "@ui5/logger"; -const log = getLogger("server:middleware:serveResources"); -import replaceStream from "replacestream"; import etag from "etag"; import fresh from "fresh"; -import fsInterface from "@ui5/fs/fsInterface"; - -const rProperties = /\.properties$/i; -const rReplaceVersion = /\.(library|js|json)$/i; -const rManifest = /\/manifest\.json$/i; -const rResourcesPrefix = /^\/resources\//i; -const rTestResourcesPrefix = /^\/test-resources\//i; function isFresh(req, res) { return fresh(req.headers, { @@ -18,7 +8,7 @@ function isFresh(req, res) { } /** - * Creates and returns the middleware to serve application resources. + * Creates and returns the middleware to serve project resources. * * @module @ui5/server/middleware/serveResources * @param {object} parameters Parameters @@ -30,90 +20,23 @@ function createMiddleware({resources, middlewareUtil}) { return async function serveResources(req, res, next) { try { const pathname = middlewareUtil.getPathname(req); - let resource = await resources.all.byPath(pathname); - if (!resource) { // Not found - if (!rManifest.test(pathname) || !rResourcesPrefix.test(pathname)) { - next(); - return; - } - log.verbose(`Could not find manifest.json for ${pathname}. ` + - `Checking for .library file to generate manifest.json from.`); - const {default: generateLibraryManifest} = await import("./helper/generateLibraryManifest.js"); - // Attempt to find a .library file, which is required for generating a manifest.json - const dotLibraryPath = pathname.replace(rManifest, "/.library"); - const dotLibraryResource = await resources.all.byPath(dotLibraryPath); - if (dotLibraryResource && dotLibraryResource.getProject()?.getType() === "library") { - resource = await generateLibraryManifest(middlewareUtil, dotLibraryResource); - } - if (!resource) { - // Not a library project, missing .library file or other reason for failed manifest.json generation - next(); - return; - } - } else if ( - rManifest.test(pathname) && !rTestResourcesPrefix.test(pathname) && - resource.getProject()?.getNamespace() - ) { - // Special handling for manifest.json file by adding additional content to the served manifest.json - // NOTE: This should only be done for manifest.json files that exist in the sources, - // not in test-resources. - // Files created by generateLibraryManifest (see above) should not be handled in here. - // Only manifest.json files in library / application projects should be handled. - // resource.getProject.getNamespace() returns null for all other kind of projects. - const {default: manifestEnhancer} = await import("@ui5/builder/processors/manifestEnhancer"); - await manifestEnhancer({ - resources: [resource], - // Ensure that only files within the manifest's project are accessible - // Using the "runtime" style to match the style used by the UI5 server - fs: fsInterface(resource.getProject().getReader({style: "runtime"})) - }); + const resource = await resources.all.byPath(pathname); + if (!resource) { + // Not found + next(); + return; } const resourcePath = resource.getPath(); - if (rProperties.test(resourcePath)) { - // Special handling for *.properties files escape non ascii characters. - const {default: nonAsciiEscaper} = await import("@ui5/builder/processors/nonAsciiEscaper"); - const project = resource.getProject(); - let propertiesFileSourceEncoding = project?.getPropertiesFileSourceEncoding?.(); - - if (!propertiesFileSourceEncoding) { - if (project && project.getSpecVersion().lte("1.1")) { - // default encoding to "ISO-8859-1" for old specVersions - propertiesFileSourceEncoding = "ISO-8859-1"; - } else { - // default encoding to "UTF-8" for all projects starting with specVersion 2.0 - propertiesFileSourceEncoding = "UTF-8"; - } - } - const encoding = nonAsciiEscaper.getEncodingFromAlias(propertiesFileSourceEncoding); - await nonAsciiEscaper({ - resources: [resource], options: { - encoding - } - }); - } - const {contentType, charset} = middlewareUtil.getMimeInfo(resourcePath); + const {contentType} = middlewareUtil.getMimeInfo(resourcePath); if (!res.getHeader("Content-Type")) { res.setHeader("Content-Type", contentType); } // Enable ETag caching - const statInfo = resource.getStatInfo(); - if (statInfo?.size !== undefined && !resource.isModified()) { - let etagHeader = etag(statInfo); - if (resource.getProject()) { - // Add project version to ETag to invalidate cache when project version changes. - // This is necessary to invalidate files with ${version} placeholders. - etagHeader = etagHeader.slice(0, -1) + `-${resource.getProject().getVersion()}"`; - } - res.setHeader("ETag", etagHeader); - } else { - // Fallback to buffer if stats are not available or insufficient or resource is modified. - // Modified resources must use the buffer for cache invalidation so that UI5 CLI changes - // invalidate the cache even when the original resource is not modified. - res.setHeader("ETag", etag(await resource.getBuffer())); - } + const resourceIntegrity = await resource.getIntegrity(); + res.setHeader("ETag", etag(resourceIntegrity)); if (isFresh(req, res)) { // client has a fresh copy of the resource @@ -122,22 +45,9 @@ function createMiddleware({resources, middlewareUtil}) { return; } - let stream = resource.getStream(); - - // Only execute version replacement for UTF-8 encoded resources because replaceStream will always output - // UTF-8 anyways. - // Also, only process .library, *.js and *.json files. Just like it's done in Application- - // and LibraryBuilder - if ((!charset || charset === "UTF-8") && rReplaceVersion.test(resourcePath)) { - if (resource.getProject()) { - stream.setEncoding("utf8"); - stream = stream.pipe(replaceStream("${version}", resource.getProject().getVersion())); - } else { - log.verbose(`Project missing from resource ${pathname}"`); - } - } - - stream.pipe(res); + // Pipe resource stream to response + // TODO: Check whether we can optimize this for small or even all resources by using getBuffer() + resource.getStream().pipe(res); } catch (err) { next(err); } From 7f4fd3fa50d702fdca7d2c522fd8552427427beb Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 9 Jan 2026 10:40:00 +0100 Subject: [PATCH 068/223] refactor(project): Add env variable to skip cache update For debugging and testing --- packages/project/lib/build/ProjectBuilder.js | 5 ++++- packages/project/lib/build/cache/ProjectBuildCache.js | 4 +++- packages/project/lib/build/helpers/WatchHandler.js | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 774b31759b3..81995e6be56 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -297,7 +297,7 @@ class ProjectBuilder { pWrites.push(this._writeResults(projectBuildContext, fsTarget)); } - if (!alreadyBuilt.includes(projectName)) { + if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_CACHE_UPDATE) { this.#log.verbose(`Saving cache...`); const buildManifest = await createBuildManifest( project, @@ -375,6 +375,9 @@ class ProjectBuilder { pWrites.push(this._writeResults(projectBuildContext, fsTarget)); } + if (process.env.UI5_BUILD_NO_CACHE_UPDATE) { + continue; + } this.#log.verbose(`Updating cache...`); const buildManifest = await createBuildManifest( project, diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 31a86938de1..7fd747ff7ec 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -153,9 +153,11 @@ export default class ProjectBuildCache { if (taskCache) { let deltaInfo; if (this.#invalidatedTasks.has(taskName)) { - log.verbose(`Task cache for task ${taskName} has been invalidated, updating indices...`); const invalidationInfo = this.#invalidatedTasks.get(taskName); + log.verbose(`Task cache for task ${taskName} has been invalidated, updating indices ` + + `with ${invalidationInfo.changedProjectResourcePaths.size} changed project resource paths and ` + + `${invalidationInfo.changedDependencyResourcePaths.size} changed dependency resource paths...`); deltaInfo = await taskCache.updateIndices( invalidationInfo.changedProjectResourcePaths, invalidationInfo.changedDependencyResourcePaths, diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index f67cfc9cabe..2d55d7b1237 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -79,8 +79,8 @@ class WatchHandler extends EventEmitter { } #processQueue() { - if (!this.#ready || this.#updateInProgress) { - // Prevent concurrent updates + if (!this.#ready || this.#updateInProgress || !this.#sourceChanges.size) { + // Prevent concurrent or premature processing return; } From 27c1135b6c9ebd0899420cf94d22317bb33c68ab Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 9 Jan 2026 11:49:47 +0100 Subject: [PATCH 069/223] refactor(project): Fix hash tree updates * Removing resources now properly cleans empty parent directories * Use correct reader for stage signature creation --- .../project/lib/build/cache/BuildTaskCache.js | 9 +- .../lib/build/cache/ProjectBuildCache.js | 12 +- .../project/lib/build/cache/index/HashTree.js | 205 ++++++++++-------- .../lib/build/cache/index/ResourceIndex.js | 49 ++--- .../lib/build/cache/index/TreeRegistry.js | 76 +++++-- packages/project/lib/build/cache/utils.js | 18 +- .../test/lib/build/cache/index/HashTree.js | 123 ++++++++--- .../lib/build/cache/index/TreeRegistry.js | 156 +++++++++++-- 8 files changed, 435 insertions(+), 213 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 290b7bf2d89..4ccaf0126f3 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -228,12 +228,10 @@ export default class BuildTaskCache { * a unique combination of resources that were accessed during task execution. * Used to look up cached build stages. * - * @param {module:@ui5/fs.AbstractReader} [projectReader] - Reader for project resources (currently unused) - * @param {module:@ui5/fs.AbstractReader} [dependencyReader] - Reader for dependency resources (currently unused) * @returns {Promise} Array of stage signature strings * @throws {Error} If resource index is missing for any request set */ - async getPossibleStageSignatures(projectReader, dependencyReader) { + async getPossibleStageSignatures() { await this.#initResourceRequests(); const requestSetIds = this.#resourceRequests.getAllNodeIds(); const signatures = requestSetIds.map((requestSetId) => { @@ -287,7 +285,7 @@ export default class BuildTaskCache { let resourceIndex; if (setId) { resourceIndex = this.#resourceRequests.getMetadata(setId).resourceIndex; - // await resourceIndex.updateResources(resourcesRead); // Index was already updated before the task executed + // Index was already updated before the task executed } else { // New request set, check whether we can create a delta const metadata = {}; // Will populate with resourceIndex below @@ -384,7 +382,8 @@ export default class BuildTaskCache { for (const {treeStats} of res) { for (const [tree, stats] of treeStats) { if (stats.removed.length > 0) { - // If resources have been removed, we currently decide to not rely on any cache + // If the update process removed resources from that tree, this means that using it in a + // differential build might lead to stale removed resources return; } const numberOfChanges = stats.added.length + stats.updated.length; diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 7fd747ff7ec..4eacd5d81e8 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -78,7 +78,9 @@ export default class ProjectBuildCache { async #init() { this.#resourceIndex = await this.#initResourceIndex(); this.#buildManifest = await this.#loadBuildManifest(); - this.#requiresInitialBuild = !(await this.#loadIndexCache()); + const hasIndexCache = await this.#loadIndexCache(); + const requiresDepdendencyResources = true; // TODO: Determine dynamically using task caches + this.#requiresInitialBuild = !hasIndexCache || requiresDepdendencyResources; } /** @@ -147,6 +149,8 @@ export default class ProjectBuildCache { async prepareTaskExecution(taskName) { const stageName = this.#getStageNameForTask(taskName); const taskCache = this.#taskCache.get(taskName); + // Store current project reader (= state of the previous stage) for later use (e.g. in recordTaskResult) + this.#currentProjectReader = this.#project.getReader(); // Switch project to new stage this.#project.useStage(stageName); log.verbose(`Preparing task execution for task ${taskName} in project ${this.#project.getName()}...`); @@ -161,7 +165,7 @@ export default class ProjectBuildCache { deltaInfo = await taskCache.updateIndices( invalidationInfo.changedProjectResourcePaths, invalidationInfo.changedDependencyResourcePaths, - this.#project.getReader(), this.#dependencyReader); + this.#currentProjectReader, this.#dependencyReader); } // else: Index will be created upon task completion // After index update, try to find cached stages for the new signatures @@ -191,8 +195,6 @@ export default class ProjectBuildCache { `and ${deltaInfo.changedProjectResourcePaths.size} changed project resource paths and ` + `${deltaInfo.changedDependencyResourcePaths.size} changed dependency resource paths.`); - // Store current project reader for later use in recordTaskResult - this.#currentProjectReader = this.#project.getReader(); return { previousStageCache: deltaStageCache, newSignature: deltaInfo.newSignature, @@ -204,8 +206,6 @@ export default class ProjectBuildCache { } else { log.verbose(`No task cache found`); } - // Store current project reader for later use in recordTaskResult - this.#currentProjectReader = this.#project.getReader(); return false; // Task needs to be executed } diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index d70a221817c..4b4bd264a01 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -138,13 +138,13 @@ export default class HashTree { * Initial resources to populate the tree. Each resource should have a path and optional metadata. * @param {object} options * @param {TreeRegistry} [options.registry] Optional registry for coordinated batch updates across multiple trees - * @param {number} [options.indexTimestamp] Timestamp when the resource index was created (for metadata comparison) + * @param {number} [options.indexTimestamp] Timestamp of the latest resource metadata update * @param {TreeNode} [options._root] Internal: pre-existing root node for derived trees (enables structural sharing) */ constructor(resources = null, options = {}) { this.registry = options.registry || null; this.root = options._root || new TreeNode("", "directory"); - this.#indexTimestamp = options.indexTimestamp || Date.now(); + this.#indexTimestamp = options.indexTimestamp; // Register with registry if provided if (this.registry) { @@ -380,8 +380,10 @@ export default class HashTree { return this.#indexTimestamp; } - _updateIndexTimestamp() { - this.#indexTimestamp = Date.now(); + setIndexTimestamp(timestamp) { + if (timestamp) { + this.#indexTimestamp = timestamp; + } } /** @@ -434,86 +436,86 @@ export default class HashTree { return derived; } - /** - * Update multiple resources efficiently. - * - * When a registry is attached, schedules updates for batch processing. - * Otherwise, updates all resources immediately, collecting affected directories - * and recomputing hashes bottom-up for optimal performance. - * - * Skips resources whose metadata hasn't changed (optimization). - * - * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to update - * @returns {Promise>} Paths of resources that actually changed - */ - async updateResources(resources) { - if (!resources || resources.length === 0) { - return []; - } - - const changedResources = []; - const affectedPaths = new Set(); - - // Update all resources and collect affected directory paths - for (const resource of resources) { - const resourcePath = resource.getOriginalPath(); - const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); - - // Find the resource node - const node = this._findNode(resourcePath); - if (!node || node.type !== "resource") { - throw new Error(`Resource not found: ${resourcePath}`); - } - - // Create metadata object from current node state - const currentMetadata = { - integrity: node.integrity, - lastModified: node.lastModified, - size: node.size, - inode: node.inode - }; - - // Check whether resource actually changed - const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); - if (isUnchanged) { - continue; // Skip unchanged resources - } - - // Update resource metadata - node.integrity = await resource.getIntegrity(); - node.lastModified = resource.getLastModified(); - node.size = await resource.getSize(); - node.inode = resource.getInode(); - changedResources.push(resourcePath); - - // Recompute resource hash - this._computeHash(node); - - // Mark all ancestor directories as needing recomputation - for (let i = 0; i < parts.length; i++) { - affectedPaths.add(parts.slice(0, i).join(path.sep)); - } - } - - // Recompute directory hashes bottom-up - const sortedPaths = Array.from(affectedPaths).sort((a, b) => { - // Sort by depth (deeper first) and then alphabetically - const depthA = a.split(path.sep).length; - const depthB = b.split(path.sep).length; - if (depthA !== depthB) return depthB - depthA; - return a.localeCompare(b); - }); - - for (const dirPath of sortedPaths) { - const node = this._findNode(dirPath); - if (node && node.type === "directory") { - this._computeHash(node); - } - } - - this._updateIndexTimestamp(); - return changedResources; - } + // /** + // * Update multiple resources efficiently. + // * + // * When a registry is attached, schedules updates for batch processing. + // * Otherwise, updates all resources immediately, collecting affected directories + // * and recomputing hashes bottom-up for optimal performance. + // * + // * Skips resources whose metadata hasn't changed (optimization). + // * + // * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to update + // * @returns {Promise>} Paths of resources that actually changed + // */ + // async updateResources(resources) { + // if (!resources || resources.length === 0) { + // return []; + // } + + // const changedResources = []; + // const affectedPaths = new Set(); + + // // Update all resources and collect affected directory paths + // for (const resource of resources) { + // const resourcePath = resource.getOriginalPath(); + // const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + + // // Find the resource node + // const node = this._findNode(resourcePath); + // if (!node || node.type !== "resource") { + // throw new Error(`Resource not found: ${resourcePath}`); + // } + + // // Create metadata object from current node state + // const currentMetadata = { + // integrity: node.integrity, + // lastModified: node.lastModified, + // size: node.size, + // inode: node.inode + // }; + + // // Check whether resource actually changed + // const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); + // if (isUnchanged) { + // continue; // Skip unchanged resources + // } + + // // Update resource metadata + // node.integrity = await resource.getIntegrity(); + // node.lastModified = resource.getLastModified(); + // node.size = await resource.getSize(); + // node.inode = resource.getInode(); + // changedResources.push(resourcePath); + + // // Recompute resource hash + // this._computeHash(node); + + // // Mark all ancestor directories as needing recomputation + // for (let i = 0; i < parts.length; i++) { + // affectedPaths.add(parts.slice(0, i).join(path.sep)); + // } + // } + + // // Recompute directory hashes bottom-up + // const sortedPaths = Array.from(affectedPaths).sort((a, b) => { + // // Sort by depth (deeper first) and then alphabetically + // const depthA = a.split(path.sep).length; + // const depthB = b.split(path.sep).length; + // if (depthA !== depthB) return depthB - depthA; + // return a.localeCompare(b); + // }); + + // for (const dirPath of sortedPaths) { + // const node = this._findNode(dirPath); + // if (node && node.type === "directory") { + // this._computeHash(node); + // } + // } + + // this._updateIndexTimestamp(); + // return changedResources; + // } /** * Upsert multiple resources (insert if new, update if exists). @@ -526,18 +528,19 @@ export default class HashTree { * Skips resources whose metadata hasn't changed (optimization). * * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to upsert + * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed * @returns {Promise<{added: Array, updated: Array, unchanged: Array}|undefined>} * Status report: arrays of paths by operation type. * Undefined if using registry (results determined during flush). */ - async upsertResources(resources) { + async upsertResources(resources, newIndexTimestamp) { if (!resources || resources.length === 0) { return {added: [], updated: [], unchanged: []}; } if (this.registry) { for (const resource of resources) { - this.registry.scheduleUpsert(resource); + this.registry.scheduleUpsert(resource, newIndexTimestamp); } // When using registry, actual results are determined during flush return; @@ -619,8 +622,7 @@ export default class HashTree { this._computeHash(node); } } - - this._updateIndexTimestamp(); + this.setIndexTimestamp(newIndexTimestamp); return {added, updated, unchanged}; } @@ -662,15 +664,18 @@ export default class HashTree { throw new Error("Cannot remove root"); } - // Navigate to parent + // Navigate to parent, keeping track of the path + const pathNodes = [this.root]; let current = this.root; let pathExists = true; + for (let i = 0; i < parts.length - 1; i++) { if (!current.children.has(parts[i])) { pathExists = false; break; } current = current.children.get(parts[i]); + pathNodes.push(current); } if (!pathExists) { @@ -684,9 +689,26 @@ export default class HashTree { if (wasRemoved) { removed.push(resourcePath); - // Mark ancestors for recomputation + + // Clean up empty parent directories bottom-up + for (let i = parts.length - 1; i > 0; i--) { + const parentNode = pathNodes[i]; + if (parentNode.children.size === 0) { + // Directory is empty, remove it from its parent + const grandparentNode = pathNodes[i - 1]; + grandparentNode.children.delete(parts[i - 1]); + } else { + // Directory still has children, stop cleanup + break; + } + } + + // Mark ancestors for recomputation (only up to where directories still exist) for (let i = 0; i < parts.length; i++) { - affectedPaths.add(parts.slice(0, i).join(path.sep)); + const ancestorPath = parts.slice(0, i).join(path.sep); + if (this._findNode(ancestorPath)) { + affectedPaths.add(ancestorPath); + } } } else { notFound.push(resourcePath); @@ -708,7 +730,6 @@ export default class HashTree { } } - this._updateIndexTimestamp(); return {removed, notFound}; } diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index fc866d0b7a3..18f64371d62 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -30,18 +30,15 @@ import {createResourceIndex} from "../utils.js"; */ export default class ResourceIndex { #tree; - #indexTimestamp; /** * Creates a new ResourceIndex instance. * * @param {HashTree} tree - The hash tree containing resource metadata - * @param {number} [indexTimestamp] - Timestamp when the index was created (defaults to current time) * @private */ - constructor(tree, indexTimestamp) { + constructor(tree) { this.#tree = tree; - this.#indexTimestamp = indexTimestamp || Date.now(); } /** @@ -53,12 +50,13 @@ export default class ResourceIndex { * * @param {Array<@ui5/fs/Resource>} resources - Resources to index * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees + * @param {number} indexTimestamp Timestamp at which the provided resources have been indexed * @returns {Promise} A new resource index * @public */ - static async create(resources, registry) { + static async create(resources, registry, indexTimestamp) { const resourceIndex = await createResourceIndex(resources); - const tree = new HashTree(resourceIndex, {registry}); + const tree = new HashTree(resourceIndex, {registry, indexTimestamp}); return new ResourceIndex(tree); } @@ -77,20 +75,21 @@ export default class ResourceIndex { * @param {number} indexCache.indexTimestamp - Timestamp of cached index * @param {object} indexCache.indexTree - Cached hash tree structure * @param {Array<@ui5/fs/Resource>} resources - Current resources to compare against cache + * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise<{changedPaths: string[], resourceIndex: ResourceIndex}>} * Object containing array of all changed resource paths and the updated index * @public */ - static async fromCacheWithDelta(indexCache, resources, registry) { + static async fromCacheWithDelta(indexCache, resources, newIndexTimestamp, registry) { const {indexTimestamp, indexTree} = indexCache; const tree = HashTree.fromCache(indexTree, {indexTimestamp, registry}); const currentResourcePaths = new Set(resources.map((resource) => resource.getOriginalPath())); - const removed = tree.getResourcePaths().filter((resourcePath) => { + const removedPaths = tree.getResourcePaths().filter((resourcePath) => { return !currentResourcePaths.has(resourcePath); }); - await tree.removeResources(removed); - const {added, updated} = await tree.upsertResources(resources); + const {removed} = await tree.removeResources(removedPaths); + const {added, updated} = await tree.upsertResources(resources, newIndexTimestamp); return { changedPaths: [...added, ...updated, ...removed], resourceIndex: new ResourceIndex(tree), @@ -131,7 +130,7 @@ export default class ResourceIndex { * @public */ clone() { - const cloned = new ResourceIndex(this.#tree.clone(), this.#indexTimestamp); + const cloned = new ResourceIndex(this.#tree.clone()); return cloned; } @@ -155,19 +154,19 @@ export default class ResourceIndex { return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); } - /** - * Updates existing resources in the index. - * - * Updates metadata for resources that already exist in the index. - * Resources not present in the index are ignored. - * - * @param {Array<@ui5/fs/Resource>} resources - Resources to update - * @returns {Promise} Array of paths for resources that were updated - * @public - */ - async updateResources(resources) { - return await this.#tree.updateResources(resources); - } + // /** + // * Updates existing resources in the index. + // * + // * Updates metadata for resources that already exist in the index. + // * Resources not present in the index are ignored. + // * + // * @param {Array<@ui5/fs/Resource>} resources - Resources to update + // * @returns {Promise} Array of paths for resources that were updated + // * @public + // */ + // async updateResources(resources) { + // return await this.#tree.updateResources(resources); + // } /** * Compares this index against a base index and returns metadata @@ -234,7 +233,7 @@ export default class ResourceIndex { */ toCacheObject() { return { - indexTimestamp: this.#indexTimestamp, + indexTimestamp: this.#tree.getIndexTimestamp(), indexTree: this.#tree.toCacheObject(), }; } diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 1831d5753e0..cba02d73729 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -23,6 +23,7 @@ export default class TreeRegistry { trees = new Set(); pendingUpserts = new Map(); pendingRemovals = new Set(); + pendingTimestampUpdate; /** * Register a HashTree instance with this registry for coordinated updates. @@ -48,18 +49,6 @@ export default class TreeRegistry { this.trees.delete(tree); } - /** - * Schedule a resource update to be applied during flush(). - * - * This method delegates to scheduleUpsert() for backward compatibility. - * Prefer using scheduleUpsert() directly for new code. - * - * @param {@ui5/fs/Resource} resource - Resource instance to update - */ - scheduleUpdate(resource) { - this.scheduleUpsert(resource); - } - /** * Schedule a resource upsert (insert or update) to be applied during flush(). * @@ -68,12 +57,14 @@ export default class TreeRegistry { * Scheduling an upsert cancels any pending removal for the same resource path. * * @param {@ui5/fs/Resource} resource - Resource instance to upsert + * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed */ - scheduleUpsert(resource) { + scheduleUpsert(resource, newIndexTimestamp) { const resourcePath = resource.getOriginalPath(); this.pendingUpserts.set(resourcePath, resource); // Cancel any pending removal for this path this.pendingRemovals.delete(resourcePath); + this.pendingTimestampUpdate = newIndexTimestamp; } /** @@ -160,7 +151,7 @@ export default class TreeRegistry { for (const tree of this.trees) { const parentNode = tree._findNode(parentPath); if (parentNode && parentNode.type === "directory" && parentNode.children.has(resourceName)) { - treesWithResource.push({tree, parentNode}); + treesWithResource.push({tree, parentNode, pathNodes: this._getPathNodes(tree, parts)}); } } @@ -169,12 +160,34 @@ export default class TreeRegistry { const {parentNode} = treesWithResource[0]; parentNode.children.delete(resourceName); - for (const {tree} of treesWithResource) { + // Clean up empty parent directories in all affected trees + for (const {tree, pathNodes} of treesWithResource) { + // Clean up empty parent directories bottom-up + for (let i = parts.length - 1; i > 0; i--) { + const currentDirNode = pathNodes[i]; + if (currentDirNode && currentDirNode.children.size === 0) { + // Directory is empty, remove it from its parent + const parentDirNode = pathNodes[i - 1]; + if (parentDirNode) { + parentDirNode.children.delete(parts[i - 1]); + } + } else { + // Directory still has children, stop cleanup for this tree + break; + } + } + if (!affectedTrees.has(tree)) { affectedTrees.set(tree, new Set()); } - this._markAncestorsAffected(tree, parts.slice(0, -1), affectedTrees); + // Mark ancestors for recomputation (only up to where directories still exist) + for (let i = 0; i < parts.length; i++) { + const ancestorPath = parts.slice(0, i).join(path.sep); + if (tree._findNode(ancestorPath)) { + affectedTrees.get(tree).add(ancestorPath); + } + } // Track per-tree removal treeStats.get(tree).removed.push(resourcePath); @@ -320,12 +333,15 @@ export default class TreeRegistry { tree._computeHash(node); } } - tree._updateIndexTimestamp(); + if (this.pendingTimestampUpdate) { + tree.setIndexTimestamp(this.pendingTimestampUpdate); + } } // Clear all pending operations this.pendingUpserts.clear(); this.pendingRemovals.clear(); + this.pendingTimestampUpdate = null; return { added: addedResources, @@ -336,6 +352,32 @@ export default class TreeRegistry { }; } + /** + * Get all nodes along a path from root to the target. + * + * Returns an array of TreeNode objects representing the full path, + * starting with root at index 0 and ending with the target node. + * + * @param {import('./HashTree.js').default} tree - Tree to traverse + * @param {string[]} pathParts - Path components to follow + * @returns {Array} Array of TreeNode objects along the path + * @private + */ + _getPathNodes(tree, pathParts) { + const nodes = [tree.root]; + let current = tree.root; + + for (let i = 0; i < pathParts.length - 1; i++) { + if (!current.children.has(pathParts[i])) { + break; + } + current = current.children.get(pathParts[i]); + nodes.push(current); + } + + return nodes; + } + /** * Mark all ancestor directories in a tree as requiring hash recomputation. * diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 7bd41ac0b86..2b16d6105ca 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -13,7 +13,7 @@ * * @param {object} resource Resource instance to compare * @param {ResourceMetadata} resourceMetadata Resource metadata to compare against - * @param {number} indexTimestamp Timestamp of the metadata creation + * @param {number} [indexTimestamp] Timestamp of the metadata creation * @returns {Promise} True if resource is found to match the metadata * @throws {Error} If resource or metadata is undefined */ @@ -23,7 +23,7 @@ export async function matchResourceMetadata(resource, resourceMetadata, indexTim } const currentLastModified = resource.getLastModified(); - if (currentLastModified > indexTimestamp) { + if (indexTimestamp && currentLastModified > indexTimestamp) { // Resource modified after index was created, no need for further checks return false; } @@ -59,7 +59,7 @@ export async function matchResourceMetadata(resource, resourceMetadata, indexTim * * @param {object} resource - Resource instance with methods: getInode(), getSize(), getLastModified(), getIntegrity() * @param {ResourceMetadata} cachedMetadata - Cached metadata from the tree - * @param {number} indexTimestamp - Timestamp when the tree state was created + * @param {number} [indexTimestamp] - Timestamp when the tree state was created * @returns {Promise} True if resource content is unchanged * @throws {Error} If resource or metadata is undefined */ @@ -69,16 +69,16 @@ export async function matchResourceMetadataStrict(resource, cachedMetadata, inde } // Check 1: Inode mismatch would indicate file replacement (comparison only if inodes are provided) - const currentInode = resource.getInode(); - if (cachedMetadata.inode !== undefined && currentInode !== undefined && - currentInode !== cachedMetadata.inode) { - return false; - } + // const currentInode = resource.getInode(); + // if (cachedMetadata.inode !== undefined && currentInode !== undefined && + // currentInode !== cachedMetadata.inode) { + // return false; + // } // Check 2: Modification time unchanged would suggest no update needed const currentLastModified = resource.getLastModified(); if (currentLastModified === cachedMetadata.lastModified) { - if (currentLastModified !== indexTimestamp) { + if (indexTimestamp && currentLastModified !== indexTimestamp) { // File has not been modified since last indexing. No update needed return true; } // else: Edge case. File modified exactly at index time diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index 3d9711962a0..7617852893c 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -69,8 +69,8 @@ test("Updating resources in two trees produces same root hash", async (t) => { // Update same resource in both trees const resource = createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1); - await tree1.updateResources([resource]); - await tree2.updateResources([resource]); + await tree1.upsertResources([resource]); + await tree2.upsertResources([resource]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should have same root hash after identical updates"); @@ -89,13 +89,13 @@ test("Multiple updates in same order produce same root hash", async (t) => { const indexTimestamp = tree1.getIndexTimestamp(); // Update multiple resources in same order - await tree1.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); - await tree1.updateResources([createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)]); - await tree1.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); + await tree1.upsertResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree1.upsertResources([createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)]); + await tree1.upsertResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); - await tree2.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); - await tree2.updateResources([createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)]); - await tree2.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); + await tree2.upsertResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree2.upsertResources([createMockResource("dir/d.js", "new-hash-d", indexTimestamp + 1, 401, 4)]); + await tree2.upsertResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should have same root hash after same sequence of updates"); @@ -113,13 +113,13 @@ test("Multiple updates in different order produce same root hash", async (t) => const indexTimestamp = tree1.getIndexTimestamp(); // Update in different orders - await tree1.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); - await tree1.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); - await tree1.updateResources([createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)]); + await tree1.upsertResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree1.upsertResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); + await tree1.upsertResources([createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)]); - await tree2.updateResources([createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)]); - await tree2.updateResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); - await tree2.updateResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); + await tree2.upsertResources([createMockResource("c.js", "new-hash-c", indexTimestamp + 1, 301, 3)]); + await tree2.upsertResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)]); + await tree2.upsertResources([createMockResource("b.js", "new-hash-b", indexTimestamp + 1, 201, 2)]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should have same root hash regardless of update order"); @@ -137,15 +137,15 @@ test("Batch updates produce same hash as individual updates", async (t) => { const indexTimestamp = tree1.getIndexTimestamp(); // Individual updates - await tree1.updateResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); - await tree1.updateResources([createMockResource("file2.js", "new-hash2", indexTimestamp + 1, 201, 2)]); + await tree1.upsertResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); + await tree1.upsertResources([createMockResource("file2.js", "new-hash2", indexTimestamp + 1, 201, 2)]); // Batch update const resources = [ createMockResource("file1.js", "new-hash1", 1001, 101, 1), createMockResource("file2.js", "new-hash2", 2001, 201, 2) ]; - await tree2.updateResources(resources); + await tree2.upsertResources(resources); t.is(tree1.getRootHash(), tree2.getRootHash(), "Batch updates should produce same hash as individual updates"); @@ -161,7 +161,7 @@ test("Updating resource changes root hash", async (t) => { const originalHash = tree.getRootHash(); const indexTimestamp = tree.getIndexTimestamp(); - await tree.updateResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); + await tree.upsertResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); const newHash = tree.getRootHash(); t.not(originalHash, newHash, @@ -179,8 +179,8 @@ test("Updating resource back to original value restores original hash", async (t const indexTimestamp = tree.getIndexTimestamp(); // Update and then revert - await tree.updateResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); - await tree.updateResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); + await tree.upsertResources([createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1)]); + await tree.upsertResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); t.is(tree.getRootHash(), originalHash, "Root hash should be restored when resource is reverted to original value"); @@ -193,11 +193,11 @@ test("updateResource returns changed resource path", async (t) => { const tree = new HashTree(resources); const indexTimestamp = tree.getIndexTimestamp(); - const changed = await tree.updateResources([ + const {updated} = await tree.upsertResources([ createMockResource("file1.js", "new-hash1", indexTimestamp + 1, 101, 1) ]); - t.deepEqual(changed, ["file1.js"], "Should return path of changed resource"); + t.deepEqual(updated, ["file1.js"], "Should return path of changed resource"); }); test("updateResource returns empty array when integrity unchanged", async (t) => { @@ -206,9 +206,9 @@ test("updateResource returns empty array when integrity unchanged", async (t) => ]; const tree = new HashTree(resources); - const changed = await tree.updateResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); + const {updated} = await tree.upsertResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); - t.deepEqual(changed, [], "Should return empty array when integrity unchanged"); + t.deepEqual(updated, [], "Should return empty array when integrity unchanged"); }); test("updateResource does not change hash when integrity unchanged", async (t) => { @@ -218,12 +218,12 @@ test("updateResource does not change hash when integrity unchanged", async (t) = const tree = new HashTree(resources); const originalHash = tree.getRootHash(); - await tree.updateResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); + await tree.upsertResources([createMockResource("file1.js", "hash1", 1000, 100, 1)]); t.is(tree.getRootHash(), originalHash, "Hash should not change when integrity unchanged"); }); -test("updateResources returns changed resource paths", async (t) => { +test("upsertResources returns changed resource paths", async (t) => { const resources = [ {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200}, @@ -238,12 +238,12 @@ test("updateResources returns changed resource paths", async (t) => { createMockResource("file2.js", "hash2", 2000, 200, 2), // unchanged createMockResource("file3.js", "new-hash3", indexTimestamp + 1, 301, 3) // Changed ]; - const changed = await tree.updateResources(resourceUpdates); + const {updated} = await tree.upsertResources(resourceUpdates); - t.deepEqual(changed, ["file1.js", "file3.js"], "Should return only changed paths"); + t.deepEqual(updated, ["file1.js", "file3.js"], "Should return only updated paths"); }); -test("updateResources returns empty array when no changes", async (t) => { +test("upsertResources returns empty array when no changes", async (t) => { const resources = [ {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100}, {path: "file2.js", integrity: "hash2", lastModified: 2000, size: 200} @@ -254,9 +254,9 @@ test("updateResources returns empty array when no changes", async (t) => { createMockResource("file1.js", "hash1", 1000, 100, 1), createMockResource("file2.js", "hash2", 2000, 200, 2) ]; - const changed = await tree.updateResources(resourceUpdates); + const {updated} = await tree.upsertResources(resourceUpdates); - t.deepEqual(changed, [], "Should return empty array when no changes"); + t.deepEqual(updated, [], "Should return empty array when no changes"); }); test("Different nested structures with same resources produce different hashes", (t) => { @@ -286,12 +286,12 @@ test("Updating unrelated resource doesn't affect consistency", async (t) => { const tree2 = new HashTree(initialResources); // Update different resources - await tree1.updateResources([createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)]); - await tree2.updateResources([createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)]); + await tree1.upsertResources([createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)]); + await tree2.upsertResources([createMockResource("file1.js", "new-hash1", Date.now(), 1024, 789)]); // Update an unrelated resource in both - await tree1.updateResources([createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)]); - await tree2.updateResources([createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)]); + await tree1.upsertResources([createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)]); + await tree2.upsertResources([createMockResource("dir/file3.js", "new-hash3", Date.now(), 2048, 790)]); t.is(tree1.getRootHash(), tree2.getRootHash(), "Trees should remain consistent after updating multiple resources"); @@ -452,6 +452,57 @@ test("removeResources - remove from nested directory", async (t) => { t.truthy(tree.hasPath("dir1/c.js"), "Should still have dir1/c.js"); }); +test("removeResources - removing last resource in directory cleans up directory", async (t) => { + const tree = new HashTree([ + {path: "dir1/dir2/only.js", integrity: "hash-only"}, + {path: "dir1/other.js", integrity: "hash-other"} + ]); + + // Verify structure before removal + t.truthy(tree.hasPath("dir1/dir2/only.js"), "Should have dir1/dir2/only.js"); + t.truthy(tree._findNode("dir1/dir2"), "Directory dir1/dir2 should exist"); + + // Remove the only resource in dir2 + const result = await tree.removeResources(["dir1/dir2/only.js"]); + + t.deepEqual(result.removed, ["dir1/dir2/only.js"], "Should remove resource"); + t.false(tree.hasPath("dir1/dir2/only.js"), "Should not have dir1/dir2/only.js"); + + // Check if empty directory is cleaned up + const dir2Node = tree._findNode("dir1/dir2"); + t.is(dir2Node, null, "Empty directory dir1/dir2 should be removed"); + + // Parent directory should still exist with other.js + t.truthy(tree.hasPath("dir1/other.js"), "Should still have dir1/other.js"); + t.truthy(tree._findNode("dir1"), "Parent directory dir1 should still exist"); +}); + +test("removeResources - cleans up deeply nested empty directories", async (t) => { + const tree = new HashTree([ + {path: "a/b/c/d/e/deep.js", integrity: "hash-deep"}, + {path: "a/sibling.js", integrity: "hash-sibling"} + ]); + + // Verify structure before removal + t.truthy(tree.hasPath("a/b/c/d/e/deep.js"), "Should have deeply nested file"); + t.truthy(tree._findNode("a/b/c/d/e"), "Deep directory should exist"); + + // Remove the only resource in the deep hierarchy + const result = await tree.removeResources(["a/b/c/d/e/deep.js"]); + + t.deepEqual(result.removed, ["a/b/c/d/e/deep.js"], "Should remove resource"); + + // All empty directories in the chain should be removed + t.is(tree._findNode("a/b/c/d/e"), null, "Directory e should be removed"); + t.is(tree._findNode("a/b/c/d"), null, "Directory d should be removed"); + t.is(tree._findNode("a/b/c"), null, "Directory c should be removed"); + t.is(tree._findNode("a/b"), null, "Directory b should be removed"); + + // Parent directory with sibling should still exist + t.truthy(tree._findNode("a"), "Directory a should still exist (has sibling.js)"); + t.truthy(tree.hasPath("a/sibling.js"), "Sibling file should still exist"); +}); + test("deriveTree - copies only modified directories (copy-on-write)", (t) => { const tree1 = new HashTree([ {path: "shared/a.js", integrity: "hash-a"}, @@ -532,7 +583,7 @@ test("deriveTree - changes propagate to derived trees (shared view)", async (t) // When tree1 is updated, tree2 sees the change (filtered view behavior) const indexTimestamp = tree1.getIndexTimestamp(); - await tree1.updateResources([ + await tree1.upsertResources([ createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) ]); diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js index 455c863ffa5..41e8f1ece93 100644 --- a/packages/project/test/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -38,7 +38,7 @@ test("TreeRegistry - schedule and flush updates", async (t) => { const originalHash = tree.getRootHash(); const resource = createMockResource("file.js", "hash2", Date.now(), 2048, 456); - registry.scheduleUpdate(resource); + registry.scheduleUpsert(resource); t.is(registry.getPendingUpdateCount(), 1, "Should have one pending update"); const result = await registry.flush(); @@ -58,8 +58,8 @@ test("TreeRegistry - flush returns only changed resources", async (t) => { ]; new HashTree(resources, {registry}); - registry.scheduleUpdate(createMockResource("file1.js", "new-hash1", timestamp, 1024, 123)); - registry.scheduleUpdate(createMockResource("file2.js", "hash2", timestamp, 2048, 124)); // unchanged + registry.scheduleUpsert(createMockResource("file1.js", "new-hash1", timestamp, 1024, 123)); + registry.scheduleUpsert(createMockResource("file2.js", "hash2", timestamp, 2048, 124)); // unchanged const result = await registry.flush(); t.deepEqual(result.updated, ["file1.js"], "Should return only changed resource"); @@ -71,7 +71,7 @@ test("TreeRegistry - flush returns empty array when no changes", async (t) => { const resources = [{path: "file.js", integrity: "hash1", lastModified: timestamp, size: 1024, inode: 123}]; new HashTree(resources, {registry}); - registry.scheduleUpdate(createMockResource("file.js", "hash1", timestamp, 1024, 123)); // same value + registry.scheduleUpsert(createMockResource("file.js", "hash1", timestamp, 1024, 123)); // same value const result = await registry.flush(); t.deepEqual(result.updated, [], "Should return empty array when no actual changes"); @@ -98,7 +98,7 @@ test("TreeRegistry - batch updates affect all trees sharing nodes", async (t) => t.is(sharedDir1, sharedDir2, "Should share the same 'shared' directory node"); // Update shared resource - registry.scheduleUpdate(createMockResource("shared/a.js", "new-hash-a", Date.now(), 2048, 999)); + registry.scheduleUpsert(createMockResource("shared/a.js", "new-hash-a", Date.now(), 2048, 999)); const result = await registry.flush(); t.deepEqual(result.updated, ["shared/a.js"], "Should report the updated resource"); @@ -123,7 +123,7 @@ test("TreeRegistry - handles missing resources gracefully during flush", async ( new HashTree([{path: "exists.js", integrity: "hash1"}], {registry}); // Schedule update for non-existent resource - registry.scheduleUpdate(createMockResource("missing.js", "hash2", Date.now(), 1024, 444)); + registry.scheduleUpsert(createMockResource("missing.js", "hash2", Date.now(), 1024, 444)); // Should not throw await t.notThrows(async () => await registry.flush(), "Should handle missing resources gracefully"); @@ -134,9 +134,9 @@ test("TreeRegistry - multiple updates to same resource", async (t) => { const tree = new HashTree([{path: "file.js", integrity: "v1"}], {registry}); const timestamp = Date.now(); - registry.scheduleUpdate(createMockResource("file.js", "v2", timestamp, 1024, 100)); - registry.scheduleUpdate(createMockResource("file.js", "v3", timestamp + 1, 1024, 100)); - registry.scheduleUpdate(createMockResource("file.js", "v4", timestamp + 2, 1024, 100)); + registry.scheduleUpsert(createMockResource("file.js", "v2", timestamp, 1024, 100)); + registry.scheduleUpsert(createMockResource("file.js", "v3", timestamp + 1, 1024, 100)); + registry.scheduleUpsert(createMockResource("file.js", "v4", timestamp + 2, 1024, 100)); t.is(registry.getPendingUpdateCount(), 1, "Should consolidate updates to same path"); @@ -159,7 +159,7 @@ test("TreeRegistry - updates without changes lead to same hash", async (t) => { const initialHash = tree.getRootHash(); const file2Hash = tree.getResourceByPath("/src/foo/file2.js").hash; - registry.scheduleUpdate(createMockResource("/src/foo/file2.js", "v1", timestamp, 1024, 200)); + registry.scheduleUpsert(createMockResource("/src/foo/file2.js", "v1", timestamp, 1024, 200)); t.is(registry.getPendingUpdateCount(), 1, "Should have one pending update"); @@ -182,7 +182,7 @@ test("TreeRegistry - unregister tree", async (t) => { t.is(registry.getTreeCount(), 1); // Flush should only affect tree2 - registry.scheduleUpdate(createMockResource("b.js", "new-hash2", Date.now(), 1024, 777)); + registry.scheduleUpsert(createMockResource("b.js", "new-hash2", Date.now(), 1024, 777)); await registry.flush(); t.notThrows(() => tree2.getRootHash(), "Tree2 should still work"); @@ -247,7 +247,7 @@ test("deriveTree - updates to shared nodes visible in all trees", async (t) => { t.is(node1Before.integrity, "original", "Original integrity"); // Update via registry - registry.scheduleUpdate(createMockResource("shared/file.js", "updated", Date.now(), 1024, 555)); + registry.scheduleUpsert(createMockResource("shared/file.js", "updated", Date.now(), 1024, 555)); await registry.flush(); // Both should see the update (same node) @@ -267,7 +267,7 @@ test("deriveTree - multiple levels of derivation", async (t) => { t.truthy(tree3.hasPath("c.js"), "Should have its own resources"); // Update shared resource - registry.scheduleUpdate(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 111)); + registry.scheduleUpsert(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 111)); await registry.flush(); // All trees should see the update @@ -292,7 +292,7 @@ test("deriveTree - efficient hash recomputation", async (t) => { const compute2Spy = sinon.spy(tree2, "_computeHash"); // Update resource in shared directory - registry.scheduleUpdate(createMockResource("dir1/a.js", "new-hash-a", Date.now(), 2048, 222)); + registry.scheduleUpsert(createMockResource("dir1/a.js", "new-hash-a", Date.now(), 2048, 222)); await registry.flush(); // Each affected directory should be hashed once per tree @@ -314,7 +314,7 @@ test("deriveTree - independent updates to different directories", async (t) => { const hash2Before = tree2.getRootHash(); // Update only in tree2's unique directory - registry.scheduleUpdate(createMockResource("dir2/b.js", "new-hash-b", Date.now(), 1024, 333)); + registry.scheduleUpsert(createMockResource("dir2/b.js", "new-hash-b", Date.now(), 1024, 333)); await registry.flush(); const hash1After = tree1.getRootHash(); @@ -383,7 +383,7 @@ test("deriveTree - complex shared structure", async (t) => { ]); // Update deeply nested shared file - registry.scheduleUpdate(createMockResource("shared/deep/nested/file1.js", "new-hash1", Date.now(), 2048, 666)); + registry.scheduleUpsert(createMockResource("shared/deep/nested/file1.js", "new-hash1", Date.now(), 2048, 666)); await registry.flush(); // Both trees should reflect the change @@ -516,6 +516,116 @@ test("removeResources - with derived trees propagates removal", async (t) => { t.truthy(tree2.hasPath("unique/c.js"), "Tree2 should still have unique/c.js"); }); +test("removeResources - with registry cleans up empty directories", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([ + {path: "dir1/dir2/only.js", integrity: "hash-only"}, + {path: "dir1/other.js", integrity: "hash-other"} + ], {registry}); + + // Verify structure before removal + t.truthy(tree.hasPath("dir1/dir2/only.js"), "Should have dir1/dir2/only.js"); + t.truthy(tree._findNode("dir1/dir2"), "Directory dir1/dir2 should exist"); + + // Remove the only resource in dir2 + await tree.removeResources(["dir1/dir2/only.js"]); + const result = await registry.flush(); + + t.true(result.removed.includes("dir1/dir2/only.js"), "Should report resource as removed"); + t.false(tree.hasPath("dir1/dir2/only.js"), "Should not have dir1/dir2/only.js"); + + // Check if empty directory is cleaned up + const dir2Node = tree._findNode("dir1/dir2"); + t.is(dir2Node, null, "Empty directory dir1/dir2 should be removed"); + + // Parent directory should still exist with other.js + t.truthy(tree.hasPath("dir1/other.js"), "Should still have dir1/other.js"); + t.truthy(tree._findNode("dir1"), "Parent directory dir1 should still exist"); +}); + +test("removeResources - with registry cleans up deeply nested empty directories", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([ + {path: "a/b/c/d/e/deep.js", integrity: "hash-deep"}, + {path: "a/sibling.js", integrity: "hash-sibling"} + ], {registry}); + + // Verify structure before removal + t.truthy(tree.hasPath("a/b/c/d/e/deep.js"), "Should have deeply nested file"); + t.truthy(tree._findNode("a/b/c/d/e"), "Deep directory should exist"); + + // Remove the only resource in the deep hierarchy + await tree.removeResources(["a/b/c/d/e/deep.js"]); + const result = await registry.flush(); + + t.true(result.removed.includes("a/b/c/d/e/deep.js"), "Should report resource as removed"); + + // All empty directories in the chain should be removed + t.is(tree._findNode("a/b/c/d/e"), null, "Directory e should be removed"); + t.is(tree._findNode("a/b/c/d"), null, "Directory d should be removed"); + t.is(tree._findNode("a/b/c"), null, "Directory c should be removed"); + t.is(tree._findNode("a/b"), null, "Directory b should be removed"); + + // Parent directory with sibling should still exist + t.truthy(tree._findNode("a"), "Directory a should still exist (has sibling.js)"); + t.truthy(tree.hasPath("a/sibling.js"), "Sibling file should still exist"); +}); + +test("removeResources - with derived trees cleans up empty directories in both trees", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new HashTree([ + {path: "shared/dir/only.js", integrity: "hash-only"}, + {path: "shared/other.js", integrity: "hash-other"} + ], {registry}); + const tree2 = tree1.deriveTree([{path: "unique/file.js", integrity: "hash-unique"}]); + + // Verify both trees share the directory structure + const sharedDirBefore = tree1.root.children.get("shared").children.get("dir"); + const sharedDirBefore2 = tree2.root.children.get("shared").children.get("dir"); + t.is(sharedDirBefore, sharedDirBefore2, "Should share the same 'shared/dir' node"); + + // Remove the only resource in shared/dir + await tree1.removeResources(["shared/dir/only.js"]); + await registry.flush(); + + // Both trees should see empty directory removal + t.is(tree1._findNode("shared/dir"), null, "Tree1: empty directory should be removed"); + t.is(tree2._findNode("shared/dir"), null, "Tree2: empty directory should be removed"); + + // Shared parent directory should still exist with other.js + t.truthy(tree1._findNode("shared"), "Tree1: shared directory should still exist"); + t.truthy(tree2._findNode("shared"), "Tree2: shared directory should still exist"); + t.truthy(tree1.hasPath("shared/other.js"), "Tree1 should still have shared/other.js"); + t.truthy(tree2.hasPath("shared/other.js"), "Tree2 should still have shared/other.js"); + + // Tree2's unique content should be unaffected + t.truthy(tree2.hasPath("unique/file.js"), "Tree2 should still have unique file"); +}); + +test("removeResources - multiple removals with registry clean up shared empty directories", async (t) => { + const registry = new TreeRegistry(); + const tree = new HashTree([ + {path: "dir1/sub1/file1.js", integrity: "hash1"}, + {path: "dir1/sub2/file2.js", integrity: "hash2"}, + {path: "dir2/file3.js", integrity: "hash3"} + ], {registry}); + + // Remove both files from dir1 (making both sub1 and sub2 empty) + await tree.removeResources(["dir1/sub1/file1.js", "dir1/sub2/file2.js"]); + await registry.flush(); + + // Both subdirectories should be cleaned up + t.is(tree._findNode("dir1/sub1"), null, "sub1 should be removed"); + t.is(tree._findNode("dir1/sub2"), null, "sub2 should be removed"); + + // dir1 should also be removed since it's now empty + const dir1 = tree._findNode("dir1"); + t.is(dir1, null, "dir1 should be removed (now empty)"); + + // dir2 should be unaffected + t.truthy(tree.hasPath("dir2/file3.js"), "dir2/file3.js should still exist"); +}); + // ============================================================================ // Combined upsert and remove operations with Registry // ============================================================================ @@ -573,7 +683,7 @@ test("TreeRegistry - flush returns per-tree statistics", async (t) => { const tree2 = new HashTree([{path: "b.js", integrity: "hash-b"}], {registry}); // Update tree1 resource - registry.scheduleUpdate(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); + registry.scheduleUpsert(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); // Add new resource - gets added to all trees registry.scheduleUpsert(createMockResource("c.js", "hash-c", Date.now(), 2048, 2)); @@ -626,7 +736,7 @@ test("TreeRegistry - per-tree statistics with shared nodes", async (t) => { t.is(sharedDir1, sharedDir2, "Should share the same 'shared' directory node"); // Update shared resource - registry.scheduleUpdate(createMockResource("shared/a.js", "new-hash-a", Date.now(), 1024, 1)); + registry.scheduleUpsert(createMockResource("shared/a.js", "new-hash-a", Date.now(), 1024, 1)); const result = await registry.flush(); @@ -660,13 +770,13 @@ test("TreeRegistry - per-tree statistics with mixed operations", async (t) => { const tree2 = tree1.deriveTree([{path: "d.js", integrity: "hash-d"}]); // Update a.js (affects both trees - shared) - registry.scheduleUpdate(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); + registry.scheduleUpsert(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); // Remove b.js (affects both trees - shared) registry.scheduleRemoval("b.js"); // Add e.js (affects both trees) registry.scheduleUpsert(createMockResource("e.js", "hash-e", Date.now(), 2048, 5)); // Update d.js (exists in tree2, will be added to tree1) - registry.scheduleUpdate(createMockResource("d.js", "new-hash-d", Date.now(), 1024, 4)); + registry.scheduleUpsert(createMockResource("d.js", "new-hash-d", Date.now(), 1024, 4)); const result = await registry.flush(); @@ -715,8 +825,8 @@ test("TreeRegistry - per-tree statistics with no changes", async (t) => { // Schedule updates with unchanged metadata // Note: These will add missing resources to the other tree - registry.scheduleUpdate(createMockResource("a.js", "hash-a", timestamp, 1024, 100)); - registry.scheduleUpdate(createMockResource("b.js", "hash-b", timestamp, 2048, 200)); + registry.scheduleUpsert(createMockResource("a.js", "hash-a", timestamp, 1024, 100)); + registry.scheduleUpsert(createMockResource("b.js", "hash-b", timestamp, 2048, 200)); const result = await registry.flush(); @@ -788,7 +898,7 @@ test("TreeRegistry - derived tree reflects base tree resource changes in statist t.is(sharedDir1, sharedDir2, "Both trees should share the 'shared' directory node"); // Update a resource that exists in base tree (and is shared with derived tree) - registry.scheduleUpdate(createMockResource("shared/resource1.js", "new-hash1", Date.now(), 2048, 100)); + registry.scheduleUpsert(createMockResource("shared/resource1.js", "new-hash1", Date.now(), 2048, 100)); // Add a new resource to the shared path registry.scheduleUpsert(createMockResource("shared/resource4.js", "hash4", Date.now(), 1024, 200)); From 887e5034a4f06880dac87d2f6e8a8a61ae05adcc Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 9 Jan 2026 15:00:48 +0100 Subject: [PATCH 070/223] refactor(project): Remove unused 'cacheDir' param --- packages/project/lib/graph/ProjectGraph.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index c673734d1de..5a3b4576bcf 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -632,7 +632,6 @@ class ProjectGraph { * @param {Array.} [parameters.excludedTasks=[]] List of tasks to be excluded. * @param {module:@ui5/project/build/ProjectBuilderOutputStyle} [parameters.outputStyle=Default] * Processes build results into a specific directory structure. - * @param {string} [parameters.cacheDir] Path to the cache directory * @param {boolean} [parameters.watch] Whether to watch for file changes and re-execute the build automatically * @returns {Promise} Promise resolving to undefined once build has finished */ @@ -643,7 +642,7 @@ class ProjectGraph { selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], outputStyle = OutputStyleEnum.Default, - cacheDir, watch, + watch, }) { this.seal(); // Do not allow further changes to the graph if (this._built) { @@ -668,7 +667,6 @@ class ProjectGraph { destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, - // cacheDir, // FIXME/TODO: Not implemented yet watch, }); } From 78169a7dc3e25736a2930d5bbf6bf34d701afcaf Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 9 Jan 2026 18:10:40 +0100 Subject: [PATCH 071/223] fix(project): Prevent projects from being always invalidated --- packages/project/lib/build/cache/ProjectBuildCache.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 4eacd5d81e8..3bf2a683bc6 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -147,6 +147,8 @@ export default class ProjectBuildCache { * @returns {Promise} True or object if task can use cache, false otherwise */ async prepareTaskExecution(taskName) { + // Remove initial build requirement once first task is prepared + this.#requiresInitialBuild = false; const stageName = this.#getStageNameForTask(taskName); const taskCache = this.#taskCache.get(taskName); // Store current project reader (= state of the previous stage) for later use (e.g. in recordTaskResult) From b9a60ce398ed71fe2bd560fda08f700225afe98a Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 12 Jan 2026 12:01:39 +0100 Subject: [PATCH 072/223] fix(project): Prevent exception when not building in watch mode --- packages/project/lib/build/ProjectBuilder.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 81995e6be56..5610899bff3 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -219,12 +219,15 @@ class ProjectBuilder { }); } - const [, watchHandler] = await Promise.all([ - this.#build(queue, projectBuildContexts, requestedProjects, fsTarget), - pWatchInit - ]); - watchHandler.setReady(); - return watchHandler; + await this.#build(queue, projectBuildContexts, requestedProjects, fsTarget); + + if (watch) { + const watchHandler = await pWatchInit; + watchHandler.setReady(); + return watchHandler; + } else { + return null; + } } async #build(queue, projectBuildContexts, requestedProjects, fsTarget) { From a749800659477d0b30264fa785d149e9d0d03695 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 9 Jan 2026 15:27:08 +0100 Subject: [PATCH 073/223] refactor(project): Only store new or modified cache entries --- .../project/lib/build/cache/BuildTaskCache.js | 13 +++++++++-- .../lib/build/cache/ProjectBuildCache.js | 23 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 4ccaf0126f3..b9220376d19 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -43,6 +43,7 @@ export default class BuildTaskCache { #readTaskMetadataCache; #treeRegistries = []; #useDifferentialUpdate = true; + #isNewOrModified; // ===== LIFECYCLE ===== @@ -66,6 +67,7 @@ export default class BuildTaskCache { if (!this.#readTaskMetadataCache) { // No cache reader provided, start with empty graph this.#resourceRequests = new ResourceRequestGraph(); + this.#isNewOrModified = true; return; } @@ -76,6 +78,7 @@ export default class BuildTaskCache { `of project '${this.#projectName}'`); } this.#resourceRequests = this.#restoreGraphFromCache(taskMetadata); + this.#isNewOrModified = false; } // ===== METADATA ACCESS ===== @@ -89,6 +92,10 @@ export default class BuildTaskCache { return this.#taskName; } + isNewOrModified() { + return this.#isNewOrModified; + } + /** * Updates resource indices for request sets affected by changed resources * @@ -284,14 +291,14 @@ export default class BuildTaskCache { let setId = this.#resourceRequests.findExactMatch(requests); let resourceIndex; if (setId) { + // Reuse existing resource index. + // Note: This index has already been updated before the task executed, so no update is necessary here resourceIndex = this.#resourceRequests.getMetadata(setId).resourceIndex; - // Index was already updated before the task executed } else { // New request set, check whether we can create a delta const metadata = {}; // Will populate with resourceIndex below setId = this.#resourceRequests.addRequestSet(requests, metadata); - const requestSet = this.#resourceRequests.getNode(setId); const parentId = requestSet.getParentId(); if (parentId) { @@ -398,6 +405,8 @@ export default class BuildTaskCache { if (!relevantTree) { return; } + this.#isNewOrModified = true; + // Update signatures for affected request sets const {requestSetId, signature: originalSignature} = trees.get(relevantTree); const newSignature = relevantTree.getRootHash(); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 3bf2a683bc6..46d20b53534 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -33,6 +33,7 @@ export default class ProjectBuildCache { #dependencyReader; #resourceIndex; #requiresInitialBuild; + #isNewOrModified; #invalidatedTasks = new Map(); @@ -332,6 +333,7 @@ export default class ProjectBuildCache { } // Reset current project reader this.#currentProjectReader = null; + this.#isNewOrModified = true; } /** @@ -579,6 +581,7 @@ export default class ProjectBuildCache { stageId, stageSignature, stageCache.resourceMetadata); this.#project.setResultStage(reader); this.#project.useResultStage(); + this.#isNewOrModified = false; return true; } @@ -695,9 +698,10 @@ export default class ProjectBuildCache { * @returns {Promise} */ async storeCache(buildManifest) { - log.verbose(`Storing build cache for project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); if (!this.#buildManifest) { + log.verbose(`Storing build manifest for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + // Write build manifest if it wasn't loaded from cache before this.#buildManifest = buildManifest; await this.#cacheManager.writeBuildManifest(this.#project.getId(), this.#buildSignature, buildManifest); } @@ -707,11 +711,20 @@ export default class ProjectBuildCache { // Store task caches for (const [taskName, taskCache] of this.#taskCache) { - await this.#cacheManager.writeTaskMetadata(this.#project.getId(), this.#buildSignature, taskName, - taskCache.toCacheObject()); + if (taskCache.isNewOrModified()) { + log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + await this.#cacheManager.writeTaskMetadata(this.#project.getId(), this.#buildSignature, taskName, + taskCache.toCacheObject()); + } } + if (!this.#isNewOrModified) { + return; + } // Store stage caches + log.verbose(`Storing stage caches for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); const stageQueue = this.#stageCache.flushCacheQueue(); await Promise.all(stageQueue.map(async ([stageId, stageSignature]) => { const {stage} = this.#stageCache.getCacheForSignature(stageId, stageSignature); @@ -739,6 +752,8 @@ export default class ProjectBuildCache { })); // Finally store index cache + log.verbose(`Storing resource index cache for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); const indexMetadata = this.#resourceIndex.toCacheObject(); await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, { ...indexMetadata, From beb2ca934628c09b993b07887c78fd9df644db94 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Sun, 11 Jan 2026 23:53:32 +0100 Subject: [PATCH 074/223] refactor(project): Refactor project cache validation --- packages/project/lib/build/ProjectBuilder.js | 86 ++- packages/project/lib/build/TaskRunner.js | 16 +- .../project/lib/build/cache/BuildTaskCache.js | 38 +- .../project/lib/build/cache/CacheManager.js | 15 +- .../lib/build/cache/ProjectBuildCache.js | 617 ++++++++++++------ .../project/lib/build/helpers/BuildContext.js | 27 +- .../lib/build/helpers/ProjectBuildContext.js | 64 +- .../project/lib/build/helpers/WatchHandler.js | 46 +- ...BuildSignature.js => getBuildSignature.js} | 16 +- .../lib/specifications/Specification.js | 4 + 10 files changed, 630 insertions(+), 299 deletions(-) rename packages/project/lib/build/helpers/{calculateBuildSignature.js => getBuildSignature.js} (60%) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 5610899bff3..c07e36a9991 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -180,7 +180,7 @@ class ProjectBuilder { } } - const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects); + const projectBuildContexts = await this._buildContext.createRequiredProjectContexts(requestedProjects); let fsTarget; if (destPath) { fsTarget = resourceFactory.createAdapter({ @@ -236,7 +236,16 @@ class ProjectBuilder { })); const alreadyBuilt = []; + const changedDependencyResources = []; for (const projectBuildContext of queue) { + if (changedDependencyResources.length) { + // Notify build cache of changed resources from dependencies + projectBuildContext.dependencyResourcesChanged(changedDependencyResources); + } + const changedResources = await projectBuildContext.determineChangedResources(); + for (const resourcePath of changedResources) { + changedDependencyResources.push(resourcePath); + } if (!await projectBuildContext.requiresBuild()) { const projectName = projectBuildContext.getProject().getName(); alreadyBuilt.push(projectName); @@ -275,19 +284,24 @@ class ProjectBuilder { try { const startTime = process.hrtime(); const pWrites = []; - for (const projectBuildContext of queue) { + while (queue.length) { + const projectBuildContext = queue.shift(); const project = projectBuildContext.getProject(); const projectName = project.getName(); const projectType = project.getType(); this.#log.verbose(`Processing project ${projectName}...`); // Only build projects that are not already build (i.e. provide a matching build manifest) - if (alreadyBuilt.includes(projectName)) { + if (alreadyBuilt.includes(projectName) || !(await projectBuildContext.requiresBuild())) { this.#log.skipProjectBuild(projectName, projectType); } else { this.#log.startProjectBuild(projectName, projectType); - await projectBuildContext.getTaskRunner().runTasks(); + const changedResources = await projectBuildContext.runTasks(); this.#log.endProjectBuild(projectName, projectType); + for (const pbc of queue) { + // Propagate resource changes to following projects + pbc.getBuildCache().dependencyResourcesChanged(changedResources); + } } if (!requestedProjects.includes(projectName)) { // Project has not been requested @@ -306,7 +320,7 @@ class ProjectBuilder { project, this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().storeCache(buildManifest)); + pWrites.push(projectBuildContext.getBuildCache().writeCache(buildManifest)); } } await Promise.all(pWrites); @@ -337,6 +351,7 @@ class ProjectBuilder { async #update(projectBuildContexts, requestedProjects, fsTarget) { const queue = []; + const changedDependencyResources = []; await this._graph.traverseDepthFirst(async ({project}) => { const projectName = project.getName(); const projectBuildContext = projectBuildContexts.get(projectName); @@ -345,6 +360,15 @@ class ProjectBuilder { // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); + + if (changedDependencyResources.length) { + // Notify build cache of changed resources from dependencies + await projectBuildContext.dependencyResourcesChanged(changedDependencyResources); + } + const changedResources = await projectBuildContext.determineChangedResources(); + for (const resourcePath of changedResources) { + changedDependencyResources.push(resourcePath); + } } }); @@ -353,7 +377,8 @@ class ProjectBuilder { })); const pWrites = []; - for (const projectBuildContext of queue) { + while (queue.length) { + const projectBuildContext = queue.shift(); const project = projectBuildContext.getProject(); const projectName = project.getName(); const projectType = project.getType(); @@ -365,8 +390,12 @@ class ProjectBuilder { } this.#log.startProjectBuild(projectName, projectType); - await projectBuildContext.runTasks(); + const changedResources = await projectBuildContext.runTasks(); this.#log.endProjectBuild(projectName, projectType); + for (const pbc of queue) { + // Propagate resource changes to following projects + pbc.getBuildCache().dependencyResourcesChanged(changedResources); + } if (!requestedProjects.includes(projectName)) { // Project has not been requested // => Its resources shall not be part of the build result @@ -386,52 +415,11 @@ class ProjectBuilder { project, this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().storeCache(buildManifest)); + pWrites.push(projectBuildContext.getBuildCache().writeCache(buildManifest)); } await Promise.all(pWrites); } - async _createRequiredBuildContexts(requestedProjects) { - const requiredProjects = new Set(this._graph.getProjectNames().filter((projectName) => { - return requestedProjects.includes(projectName); - })); - - const projectBuildContexts = new Map(); - - for (const projectName of requiredProjects) { - this.#log.verbose(`Creating build context for project ${projectName}...`); - const projectBuildContext = await this._buildContext.createProjectContext({ - project: this._graph.getProject(projectName) - }); - - projectBuildContexts.set(projectName, projectBuildContext); - - if (await projectBuildContext.requiresBuild()) { - const taskRunner = projectBuildContext.getTaskRunner(); - const requiredDependencies = await taskRunner.getRequiredDependencies(); - - if (requiredDependencies.size === 0) { - continue; - } - // This project needs to be built and required dependencies to be built as well - this._graph.getDependencies(projectName).forEach((depName) => { - if (projectBuildContexts.has(depName)) { - // Build context already exists - // => Dependency will be built - return; - } - if (!requiredDependencies.has(depName)) { - return; - } - // Add dependency to list of projects to build - requiredProjects.add(depName); - }); - } - } - - return projectBuildContexts; - } - async _getProjectFilter({ dependencyIncludes, explicitIncludes, diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 2c3b934bd4b..d9b4d227134 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -131,7 +131,7 @@ class TaskRunner { await this._executeTask(taskName, taskFunction); } } - this._buildCache.allTasksCompleted(); + return await this._buildCache.allTasksCompleted(); } /** @@ -485,13 +485,13 @@ class TaskRunner { * @returns {Promise} Resolves when task has finished */ async _executeTask(taskName, taskFunction, taskParams) { - if (this._buildCache.isTaskCacheValid(taskName)) { - // Immediately skip task if cache is valid - // Continue if cache is (potentially) invalid, in which case taskFunction will - // validate the cache thoroughly - this._log.skipTask(taskName); - return; - } + // if (this._buildCache.isTaskCacheValid(taskName)) { + // // Immediately skip task if cache is valid + // // Continue if cache is (potentially) invalid, in which case taskFunction will + // // validate the cache thoroughly + // this._log.skipTask(taskName); + // return; + // } this._taskStart = performance.now(); await taskFunction(taskParams, this._log); if (this._log.isLevelEnabled("perf")) { diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index b9220376d19..8168c27c62a 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -43,7 +43,7 @@ export default class BuildTaskCache { #readTaskMetadataCache; #treeRegistries = []; #useDifferentialUpdate = true; - #isNewOrModified; + #hasNewOrModifiedCacheEntries = true; // ===== LIFECYCLE ===== @@ -67,7 +67,7 @@ export default class BuildTaskCache { if (!this.#readTaskMetadataCache) { // No cache reader provided, start with empty graph this.#resourceRequests = new ResourceRequestGraph(); - this.#isNewOrModified = true; + this.#hasNewOrModifiedCacheEntries = true; return; } @@ -78,7 +78,7 @@ export default class BuildTaskCache { `of project '${this.#projectName}'`); } this.#resourceRequests = this.#restoreGraphFromCache(taskMetadata); - this.#isNewOrModified = false; + this.#hasNewOrModifiedCacheEntries = false; // Using cache } // ===== METADATA ACCESS ===== @@ -92,8 +92,8 @@ export default class BuildTaskCache { return this.#taskName; } - isNewOrModified() { - return this.#isNewOrModified; + hasNewOrModifiedCacheEntries() { + return this.#hasNewOrModifiedCacheEntries; } /** @@ -405,7 +405,7 @@ export default class BuildTaskCache { if (!relevantTree) { return; } - this.#isNewOrModified = true; + this.#hasNewOrModifiedCacheEntries = true; // Update signatures for affected request sets const {requestSetId, signature: originalSignature} = trees.get(relevantTree); @@ -525,6 +525,32 @@ export default class BuildTaskCache { }); } + async isAffectedByProjectChanges(changedPaths) { + await this.#initResourceRequests(); + const resourceRequests = this.#resourceRequests.getAllRequests(); + return resourceRequests.some(({type, value}) => { + if (type === "path") { + return changedPaths.includes(value); + } + if (type === "patterns") { + return micromatch(changedPaths, value).length > 0; + } + }); + } + + async isAffectedByDependencyChanges(changedPaths) { + await this.#initResourceRequests(); + const resourceRequests = this.#resourceRequests.getAllRequests(); + return resourceRequests.some(({type, value}) => { + if (type === "dep-path") { + return changedPaths.includes(value); + } + if (type === "dep-patterns") { + return micromatch(changedPaths, value).length > 0; + } + }); + } + /** * Serializes the task cache to a plain object for persistence * diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 596dbcbfdf6..28a91425305 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -161,11 +161,12 @@ export default class CacheManager { * @private * @param {string} packageName - Package/project identifier * @param {string} buildSignature - Build signature hash + * @param {string} kind "source" or "result" * @returns {string} Absolute path to the index metadata file */ - #getIndexCachePath(packageName, buildSignature) { + #getIndexCachePath(packageName, buildSignature, kind) { const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#indexDir, pkgDir, `${buildSignature}.json`); + return path.join(this.#indexDir, pkgDir, `${kind}-${buildSignature}.json`); } /** @@ -176,12 +177,13 @@ export default class CacheManager { * * @param {string} projectId - Project identifier (typically package name) * @param {string} buildSignature - Build signature hash + * @param {string} kind "source" or "result" * @returns {Promise} Parsed index cache object or null if not found * @throws {Error} If file read fails for reasons other than file not existing */ - async readIndexCache(projectId, buildSignature) { + async readIndexCache(projectId, buildSignature, kind) { try { - const metadata = await readFile(this.#getIndexCachePath(projectId, buildSignature), "utf8"); + const metadata = await readFile(this.#getIndexCachePath(projectId, buildSignature, kind), "utf8"); return JSON.parse(metadata); } catch (err) { if (err.code === "ENOENT") { @@ -203,11 +205,12 @@ export default class CacheManager { * * @param {string} projectId - Project identifier (typically package name) * @param {string} buildSignature - Build signature hash + * @param {string} kind "source" or "result" * @param {object} index - Index object containing resource tree and task metadata * @returns {Promise} */ - async writeIndexCache(projectId, buildSignature, index) { - const indexPath = this.#getIndexCachePath(projectId, buildSignature); + async writeIndexCache(projectId, buildSignature, kind, index) { + const indexPath = this.#getIndexCachePath(projectId, buildSignature, kind); await mkdir(path.dirname(indexPath), {recursive: true}); await writeFile(indexPath, JSON.stringify(index, null, 2), "utf8"); } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 46d20b53534..46971cebbb2 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -2,6 +2,7 @@ import {createResource, createProxy} from "@ui5/fs/resourceFactory"; import {getLogger} from "@ui5/logger"; import fs from "graceful-fs"; import {promisify} from "node:util"; +import crypto from "node:crypto"; import {gunzip, createGunzip} from "node:zlib"; const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; @@ -27,13 +28,23 @@ export default class ProjectBuildCache { #project; #buildSignature; - #buildManifest; + // #buildManifest; #cacheManager; #currentProjectReader; #dependencyReader; - #resourceIndex; - #requiresInitialBuild; - #isNewOrModified; + #sourceIndex; + #cachedSourceSignature; + #resultIndex; + #cachedResultSignature; + #currentResultSignature; + + #usingResultStage = false; + + // Pending changes + #changedProjectSourcePaths = new Set(); + #changedProjectResourcePaths = new Set(); + #changedDependencyResourcePaths = new Set(); + #changedResultResourcePaths = new Set(); #invalidatedTasks = new Map(); @@ -77,11 +88,72 @@ export default class ProjectBuildCache { * @returns {Promise} */ async #init() { - this.#resourceIndex = await this.#initResourceIndex(); - this.#buildManifest = await this.#loadBuildManifest(); - const hasIndexCache = await this.#loadIndexCache(); - const requiresDepdendencyResources = true; // TODO: Determine dynamically using task caches - this.#requiresInitialBuild = !hasIndexCache || requiresDepdendencyResources; + // this.#buildManifest = await this.#loadBuildManifest(); + // this.#sourceIndex = await this.#initResourceIndex(); + // const hasIndexCache = await this.#loadIndexCache(); + // const requiresDepdendencyResources = true; // TODO: Determine dynamically using task caches + // this.#requiresInitialBuild = !hasIndexCache || requiresDepdendencyResources; + } + + /** + * Determines changed resources since last build + * + * This is expected to be the first method called on the cache. + * Hence it will perform some initialization and deserialization tasks as needed. + */ + async determineChangedResources() { + // TODO: Start detached initializations in constructor and await them here? + let changedSourcePaths; + if (!this.#sourceIndex) { + changedSourcePaths = await this.#initSourceIndex(); + for (const resourcePath of changedSourcePaths) { + this.#changedProjectSourcePaths.add(resourcePath); + } + } else if (this.#changedProjectSourcePaths.size) { + changedSourcePaths = await this._updateSourceIndex(this.#changedProjectSourcePaths); + } else { + changedSourcePaths = []; + } + + if (!this.#resultIndex) { + await this.#initResultIndex(); + } + + await this.#flushPendingInputChanges(); + return changedSourcePaths; + } + + /** + * Determines whether a rebuild is needed. + * + * A rebuild is required if: + * - No task cache exists + * - Any tasks have been invalidated + * - Initial build is required (e.g., cache couldn't be loaded) + * + * @param {string[]} dependencySignatures - Sorted by name of the dependency project + * @returns {boolean} True if rebuild is needed, false if cache can be fully utilized + */ + async requiresBuild(dependencySignatures) { + if (this.#invalidatedTasks.size > 0) { + this.#usingResultStage = false; + return true; + } + + if (this.#usingResultStage && this.#invalidatedTasks.size === 0) { + return false; + } + + if (await this.#hasValidResultCache(dependencySignatures)) { + return false; + } + return true; + } + + async getResultSignature() { + // Do not include dependency signatures here. They are not relevant to consumers of this project and would + // unnecessarily invalidate their caches. + return this.#resultIndex.getSignature(); } /** @@ -92,14 +164,13 @@ export default class ProjectBuildCache { * resources have changed. If no cache exists, creates a fresh index. * * @private - * @returns {Promise} The initialized resource index * @throws {Error} If cached index signature doesn't match computed signature */ - async #initResourceIndex() { + async #initSourceIndex() { const sourceReader = this.#project.getSourceReader(); const [resources, indexCache] = await Promise.all([ await sourceReader.byGlob("/**/*"), - await this.#cacheManager.readIndexCache(this.#project.getId(), this.#buildSignature), + await this.#cacheManager.readIndexCache(this.#project.getId(), this.#buildSignature, "source"), ]); if (indexCache) { log.verbose(`Using cached resource index for project ${this.#project.getName()}`); @@ -120,17 +191,109 @@ export default class ProjectBuildCache { // Since no tasks have been invalidated, a rebuild is still necessary in this case, so that // each task can find and use its individual stage cache. // Hence requiresInitialBuild will be set to true in this case (and others. - await this.resourceChanged(changedPaths, []); + const tasksInvalidated = await this._invalidateTasks(changedPaths, []); + if (!tasksInvalidated) { + this.#cachedSourceSignature = resourceIndex.getSignature(); + } + // for (const resourcePath of changedPaths) { + // this.#changedProjectResourcePaths.add(resourcePath); + // } } else if (indexCache.indexTree.root.hash !== resourceIndex.getSignature()) { // Validate index signature matches with cached signature throw new Error( `Resource index signature mismatch for project ${this.#project.getName()}: ` + `expected ${indexCache.indexTree.root.hash}, got ${resourceIndex.getSignature()}`); + } else { + log.verbose( + `Resource index signature for project ${this.#project.getName()} matches cached signature: ` + + `${resourceIndex.getSignature()}`); + this.#cachedSourceSignature = resourceIndex.getSignature(); } - return resourceIndex; + this.#sourceIndex = resourceIndex; + return changedPaths; + } else { + // No index cache found, create new index + this.#sourceIndex = await ResourceIndex.create(resources); + return []; + } + } + + async _updateSourceIndex(resourcePaths) { + if (resourcePaths.size === 0) { + return []; } - // No index cache found, create new index - return await ResourceIndex.create(resources); + const sourceReader = this.#project.getSourceReader(); + const resources = await Promise.all(Array.from(resourcePaths).map(async (resourcePath) => { + const resource = await sourceReader.byPath(resourcePath); + if (!resource) { + throw new Error( + `Failed to update source index for project ${this.#project.getName()}: ` + + `resource at path ${resourcePath} not found in source reader`); + } + return resource; + })); + const res = await this.#sourceIndex.upsertResources(resources); + return [...res.added, ...res.updated]; + } + + async #initResultIndex() { + const indexCache = await this.#cacheManager.readIndexCache( + this.#project.getId(), this.#buildSignature, "result"); + + if (indexCache) { + log.verbose(`Using cached result resource index for project ${this.#project.getName()}`); + this.#resultIndex = await ResourceIndex.fromCache(indexCache); + this.#cachedResultSignature = this.#resultIndex.getSignature(); + } else { + this.#resultIndex = await ResourceIndex.create([]); + } + } + + #getResultStageSignature(sourceSignature, dependencySignatures) { + // Different from the project cache's "result signature", the "result stage signature" includes the + // signatures of dependencies, since they possibly affect the result stage's content. + const stageSignature = `${sourceSignature}|${dependencySignatures.join("|")}`; + return crypto.createHash("sha256").update(stageSignature).digest("hex"); + } + + /** + * Loads the cached result stage from persistent storage + * + * Attempts to load a cached result stage using the resource index signature. + * If found, creates a reader for the cached stage and sets it as the project's + * result stage. + * + * @param {string[]} dependencySignatures + * @private + * @returns {Promise} True if cache was loaded successfully, false otherwise + */ + async #hasValidResultCache(dependencySignatures) { + const stageSignature = this.#getResultStageSignature(this.#sourceIndex.getSignature(), dependencySignatures); + if (this.#currentResultSignature === stageSignature) { + // log.verbose( + // `Project ${this.#project.getName()} result stage signature unchanged: ${stageSignature}`); + // TODO: Requires setResultStage again? + return this.#usingResultStage; + } + this.#currentResultSignature = stageSignature; + const stageId = "result"; + log.verbose(`Project ${this.#project.getName()} resource index signature: ${stageSignature}`); + const stageCache = await this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, stageId, stageSignature); + + if (!stageCache) { + log.verbose( + `No cached stage found for project ${this.#project.getName()} with index signature ${stageSignature}`); + return false; + } + log.verbose( + `Using cached result stage for project ${this.#project.getName()} with index signature ${stageSignature}`); + const reader = await this.#createReaderForStageCache( + stageId, stageSignature, stageCache.resourceMetadata); + this.#project.setResultStage(reader); + this.#project.useResultStage(); + this.#usingResultStage = true; + return true; } // ===== TASK MANAGEMENT ===== @@ -148,8 +311,6 @@ export default class ProjectBuildCache { * @returns {Promise} True or object if task can use cache, false otherwise */ async prepareTaskExecution(taskName) { - // Remove initial build requirement once first task is prepared - this.#requiresInitialBuild = false; const stageName = this.#getStageNameForTask(taskName); const taskCache = this.#taskCache.get(taskName); // Store current project reader (= state of the previous stage) for later use (e.g. in recordTaskResult) @@ -184,7 +345,7 @@ export default class ProjectBuildCache { if (!stageChanged && stageCache.writtenResourcePaths.size) { // Invalidate following tasks - this.#invalidateFollowingTasks(taskName, stageCache.writtenResourcePaths); + this.#invalidateFollowingTasks(taskName, Array.from(stageCache.writtenResourcePaths)); } return true; // No need to execute the task } else if (deltaInfo) { @@ -327,13 +488,12 @@ export default class ProjectBuildCache { } // Update task cache with new metadata - if (writtenResourcePaths.size) { - log.verbose(`Task ${taskName} produced ${writtenResourcePaths.size} resources`); + if (writtenResourcePaths.length) { + log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); this.#invalidateFollowingTasks(taskName, writtenResourcePaths); } // Reset current project reader this.#currentProjectReader = null; - this.#isNewOrModified = true; } /** @@ -344,17 +504,15 @@ export default class ProjectBuildCache { * * @private * @param {string} taskName - Name of the task that wrote resources - * @param {Set} writtenResourcePaths - Paths of resources written by the task + * @param {string[]} writtenResourcePaths - Paths of resources written by the task */ async #invalidateFollowingTasks(taskName, writtenResourcePaths) { - const writtenPathsArray = Array.from(writtenResourcePaths); - // Check whether following tasks need to be invalidated const allTasks = Array.from(this.#taskCache.keys()); const taskIdx = allTasks.indexOf(taskName); for (let i = taskIdx + 1; i < allTasks.length; i++) { const nextTaskName = allTasks[i]; - if (!await this.#taskCache.get(nextTaskName).matchesChangedResources(writtenPathsArray, [])) { + if (!await this.#taskCache.get(nextTaskName).matchesChangedResources(writtenResourcePaths, [])) { continue; } if (this.#invalidatedTasks.has(nextTaskName)) { @@ -370,6 +528,27 @@ export default class ProjectBuildCache { }); } } + + for (const resourcePath of writtenResourcePaths) { + this.#changedResultResourcePaths.add(resourcePath); + } + } + + async #updateResultIndex(resourcePaths) { + const deltaReader = this.#project.getReader({excludeSourceReader: true}); + + const resources = await Promise.all(Array.from(resourcePaths).map(async (resourcePath) => { + const resource = await deltaReader.byPath(resourcePath); + if (!resource) { + throw new Error( + `Failed to update result index for project ${this.#project.getName()}: ` + + `resource at path ${resourcePath} not found in result reader`); + } + return resource; + })); + + const res = await this.#resultIndex.upsertResources(resources); + return [...res.added, ...res.updated]; } /** @@ -382,6 +561,77 @@ export default class ProjectBuildCache { return this.#taskCache.get(taskName); } + async projectSourcesChanged(changedPaths) { + for (const resourcePath of changedPaths) { + this.#changedProjectSourcePaths.add(resourcePath); + } + } + + /** + * Handles resource changes + * + * Iterates through all cached tasks and checks if any match the changed resources. + * Matching tasks are marked as invalidated and will need to be re-executed. + * Changed resource paths are accumulated if a task is already invalidated. + * + * @param {string[]} changedPaths - Changed project resource paths + * @returns {boolean} True if any task was invalidated, false otherwise + */ + // async projectResourcesChanged(changedPaths) { + // let taskInvalidated = false; + // for (const taskCache of this.#taskCache.values()) { + // if (await taskCache.isAffectedByProjectChanges(changedPaths)) { + // taskInvalidated = true; + // break; + // } + // } + // if (taskInvalidated) { + // for (const resourcePath of changedPaths) { + // this.#changedProjectResourcePaths.add(resourcePath); + // } + // } + // return taskInvalidated; + // } + + /** + * Handles resource changes and invalidates affected tasks + * + * Iterates through all cached tasks and checks if any match the changed resources. + * Matching tasks are marked as invalidated and will need to be re-executed. + * Changed resource paths are accumulated if a task is already invalidated. + * + * @param {string[]} changedPaths - Changed dependency resource paths + * @returns {boolean} True if any task was invalidated, false otherwise + */ + async dependencyResourcesChanged(changedPaths) { + // let taskInvalidated = false; + // for (const taskCache of this.#taskCache.values()) { + // if (await taskCache.isAffectedByDependencyChanges(changedPaths)) { + // taskInvalidated = true; + // break; + // } + // } + // if (taskInvalidated) { + for (const resourcePath of changedPaths) { + this.#changedDependencyResourcePaths.add(resourcePath); + } + // } + // return taskInvalidated; + } + + async #flushPendingInputChanges() { + if (this.#changedProjectSourcePaths.size === 0 && + this.#changedDependencyResourcePaths.size === 0) { + return []; + } + await this._invalidateTasks( + Array.from(this.#changedProjectSourcePaths), + Array.from(this.#changedDependencyResourcePaths)); + + // Reset pending changes + this.#changedProjectSourcePaths = new Set(); + this.#changedDependencyResourcePaths = new Set(); + } /** * Handles resource changes and invalidates affected tasks @@ -394,7 +644,7 @@ export default class ProjectBuildCache { * @param {string[]} dependencyResourcePaths - Changed dependency resource paths * @returns {boolean} True if any task was invalidated, false otherwise */ - async resourceChanged(projectResourcePaths, dependencyResourcePaths) { + async _invalidateTasks(projectResourcePaths, dependencyResourcePaths) { let taskInvalidated = false; for (const [taskName, taskCache] of this.#taskCache) { if (!await taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { @@ -420,6 +670,14 @@ export default class ProjectBuildCache { return taskInvalidated; } + // async areTasksAffectedByResource(projectResourcePaths, dependencyResourcePaths) { + // for (const taskCache of this.#taskCache.values()) { + // if (await taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { + // return true; + // } + // } + // } + /** * Gets the set of changed project resource paths for a task * @@ -447,7 +705,7 @@ export default class ProjectBuildCache { * * @returns {boolean} True if at least one task has been cached */ - hasAnyCache() { + hasAnyTaskCache() { return this.#taskCache.size > 0; } @@ -470,21 +728,7 @@ export default class ProjectBuildCache { * @returns {boolean} True if cache exists and is valid for this task */ isTaskCacheValid(taskName) { - return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName) && !this.#requiresInitialBuild; - } - - /** - * Determines whether a rebuild is needed - * - * A rebuild is required if: - * - No task cache exists - * - Any tasks have been invalidated - * - Initial build is required (e.g., cache couldn't be loaded) - * - * @returns {boolean} True if rebuild is needed, false if cache can be fully utilized - */ - requiresBuild() { - return !this.hasAnyCache() || this.#invalidatedTasks.size > 0 || this.#requiresInitialBuild; + return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName); } /** @@ -521,11 +765,18 @@ export default class ProjectBuildCache { * * This finalizes the build process by switching the project to use the * final result stage containing all build outputs. + * Also updates the result resource index accordingly. * - * @returns {void} + * @returns {Promise} Resolves with list of changed resources since the last build */ - allTasksCompleted() { + async allTasksCompleted() { this.#project.useResultStage(); + this.#usingResultStage = true; + const changedPaths = await this.#updateResultIndex(this.#changedResultResourcePaths); + + // Reset updated resource paths + this.#changedResultResourcePaths = new Set(); + return changedPaths; } /** @@ -551,38 +802,63 @@ export default class ProjectBuildCache { return `task/${taskName}`; } - // ===== SERIALIZATION ===== + // ===== CACHE SERIALIZATION ===== /** - * Loads the cached result stage from persistent storage + * Stores all cache data to persistent storage * - * Attempts to load a cached result stage using the resource index signature. - * If found, creates a reader for the cached stage and sets it as the project's - * result stage. + * This method: + * 1. Writes the build manifest (if not already written) + * 2. Stores the result stage with all resources + * 3. Writes the resource index and task metadata + * 4. Stores all stage caches from the queue * - * @private - * @returns {Promise} True if cache was loaded successfully, false otherwise + * @param {object} buildManifest - Build manifest containing metadata about the build + * @param {string} buildManifest.manifestVersion - Version of the manifest format + * @param {string} buildManifest.signature - Build signature + * @returns {Promise} */ - async #loadIndexCache() { - const stageSignature = this.#resourceIndex.getSignature(); - const stageId = "result"; - log.verbose(`Project ${this.#project.getName()} resource index signature: ${stageSignature}`); - const stageCache = await this.#cacheManager.readStageCache( - this.#project.getId(), this.#buildSignature, stageId, stageSignature); + async writeCache(buildManifest) { + // if (!this.#buildManifest) { + // log.verbose(`Storing build manifest for project ${this.#project.getName()} ` + + // `with build signature ${this.#buildSignature}`); + // // Write build manifest if it wasn't loaded from cache before + // this.#buildManifest = buildManifest; + // await this.#cacheManager.writeBuildManifest(this.#project.getId(), this.#buildSignature, buildManifest); + // } - if (!stageCache) { - log.verbose( - `No cached stage found for project ${this.#project.getName()} with index signature ${stageSignature}`); - return false; + // Store result stage + await this.#writeResultIndex(); + + // Store task caches + for (const [taskName, taskCache] of this.#taskCache) { + if (taskCache.hasNewOrModifiedCacheEntries()) { + log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + await this.#cacheManager.writeTaskMetadata(this.#project.getId(), this.#buildSignature, taskName, + taskCache.toCacheObject()); + } } - log.verbose( - `Using cached result stage for project ${this.#project.getName()} with index signature ${stageSignature}`); - const reader = await this.#createReaderForStageCache( - stageId, stageSignature, stageCache.resourceMetadata); - this.#project.setResultStage(reader); - this.#project.useResultStage(); - this.#isNewOrModified = false; - return true; + + await this.#writeStageCaches(); + + await this.#writeSourceIndex(); + } + + async #writeSourceIndex() { + if (this.#cachedSourceSignature === this.#sourceIndex.getSignature()) { + // No changes to already cached result index + return; + } + + // Finally store index cache + log.verbose(`Storing resource index cache for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + const sourceIndexObject = this.#sourceIndex.toCacheObject(); + await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "source", { + ...sourceIndexObject, + taskList: Array.from(this.#taskCache.keys()), + }); } /** @@ -595,8 +871,12 @@ export default class ProjectBuildCache { * @private * @returns {Promise} */ - async #writeResultStage() { - const stageSignature = this.#resourceIndex.getSignature(); + async #writeResultIndex() { + if (this.#cachedResultSignature === this.#resultIndex.getSignature()) { + // No changes to already cached result index + return; + } + const stageSignature = this.#currentResultSignature; const stageId = "result"; const deltaReader = this.#project.getReader({excludeSourceReader: true}); @@ -622,6 +902,49 @@ export default class ProjectBuildCache { }; await this.#cacheManager.writeStageCache( this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); + + // After all resources have been stored, write updated result index hash tree + const resultIndexObject = this.#resultIndex.toCacheObject(); + await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "result", { + ...resultIndexObject + }); + } + + async #writeStageCaches() { + // Store stage caches + log.verbose(`Storing stage caches for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + const stageQueue = this.#stageCache.flushCacheQueue(); + await Promise.all(stageQueue.map(async ([stageId, stageSignature]) => { + const {stage} = this.#stageCache.getCacheForSignature(stageId, stageSignature); + const writer = stage.getWriter(); + const reader = writer.collection ? writer.collection : writer; + const resources = await reader.byGlob("/**/*"); + const resourceMetadata = Object.create(null); + await Promise.all(resources.map(async (res) => { + // Store resource content in cacache via CacheManager + await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + + resourceMetadata[res.getOriginalPath()] = { + inode: res.getInode(), + lastModified: res.getLastModified(), + size: await res.getSize(), + integrity: await res.getIntegrity(), + }; + })); + + const metadata = { + resourceMetadata, + }; + await this.#cacheManager.writeStageCache( + this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); + })); + } + + #createBuildTaskCacheMetadataReader(taskName) { + return () => { + return this.#cacheManager.readTaskMetadata(this.#project.getId(), this.#buildSignature, taskName); + }; } /** @@ -682,85 +1005,6 @@ export default class ProjectBuildCache { } }); } - - /** - * Stores all cache data to persistent storage - * - * This method: - * 1. Writes the build manifest (if not already written) - * 2. Stores the result stage with all resources - * 3. Writes the resource index and task metadata - * 4. Stores all stage caches from the queue - * - * @param {object} buildManifest - Build manifest containing metadata about the build - * @param {string} buildManifest.manifestVersion - Version of the manifest format - * @param {string} buildManifest.signature - Build signature - * @returns {Promise} - */ - async storeCache(buildManifest) { - if (!this.#buildManifest) { - log.verbose(`Storing build manifest for project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - // Write build manifest if it wasn't loaded from cache before - this.#buildManifest = buildManifest; - await this.#cacheManager.writeBuildManifest(this.#project.getId(), this.#buildSignature, buildManifest); - } - - // Store result stage - await this.#writeResultStage(); - - // Store task caches - for (const [taskName, taskCache] of this.#taskCache) { - if (taskCache.isNewOrModified()) { - log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - await this.#cacheManager.writeTaskMetadata(this.#project.getId(), this.#buildSignature, taskName, - taskCache.toCacheObject()); - } - } - - if (!this.#isNewOrModified) { - return; - } - // Store stage caches - log.verbose(`Storing stage caches for project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - const stageQueue = this.#stageCache.flushCacheQueue(); - await Promise.all(stageQueue.map(async ([stageId, stageSignature]) => { - const {stage} = this.#stageCache.getCacheForSignature(stageId, stageSignature); - const writer = stage.getWriter(); - const reader = writer.collection ? writer.collection : writer; - const resources = await reader.byGlob("/**/*"); - const resourceMetadata = Object.create(null); - await Promise.all(resources.map(async (res) => { - // Store resource content in cacache via CacheManager - await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); - - resourceMetadata[res.getOriginalPath()] = { - inode: res.getInode(), - lastModified: res.getLastModified(), - size: await res.getSize(), - integrity: await res.getIntegrity(), - }; - })); - - const metadata = { - resourceMetadata, - }; - await this.#cacheManager.writeStageCache( - this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); - })); - - // Finally store index cache - log.verbose(`Storing resource index cache for project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - const indexMetadata = this.#resourceIndex.toCacheObject(); - await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, { - ...indexMetadata, - taskList: Array.from(this.#taskCache.keys()), - }); - } - /** * Loads and validates the build manifest from persistent storage * @@ -770,46 +1014,41 @@ export default class ProjectBuildCache { * * If validation fails, the cache is considered invalid and will be ignored. * + * @param taskName * @private * @returns {Promise} Build manifest object or undefined if not found/invalid * @throws {Error} If build signature mismatch or cache restoration fails */ - async #loadBuildManifest() { - const manifest = await this.#cacheManager.readBuildManifest(this.#project.getId(), this.#buildSignature); - if (!manifest) { - log.verbose(`No build manifest found for project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - return; - } - - try { - // Check build manifest version - const {buildManifest} = manifest; - if (buildManifest.manifestVersion !== "1.0") { - log.verbose(`Incompatible build manifest version ${manifest.version} found for project ` + - `${this.#project.getName()} with build signature ${this.#buildSignature}. Ignoring cache.`); - return; - } - // TODO: Validate manifest against a schema - - // Validate build signature match - if (this.#buildSignature !== manifest.buildManifest.signature) { - throw new Error( - `Build manifest signature ${manifest.buildManifest.signature} does not match expected ` + - `build signature ${this.#buildSignature} for project ${this.#project.getName()}`); - } - return buildManifest; - } catch (err) { - throw new Error( - `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { - cause: err - }); - } - } - - #createBuildTaskCacheMetadataReader(taskName) { - return () => { - return this.#cacheManager.readTaskMetadata(this.#project.getId(), this.#buildSignature, taskName); - }; - } + // async #loadBuildManifest() { + // const manifest = await this.#cacheManager.readBuildManifest(this.#project.getId(), this.#buildSignature); + // if (!manifest) { + // log.verbose(`No build manifest found for project ${this.#project.getName()} ` + + // `with build signature ${this.#buildSignature}`); + // return; + // } + + // try { + // // Check build manifest version + // const {buildManifest} = manifest; + // if (buildManifest.manifestVersion !== "1.0") { + // log.verbose(`Incompatible build manifest version ${manifest.version} found for project ` + + // `${this.#project.getName()} with build signature ${this.#buildSignature}. Ignoring cache.`); + // return; + // } + // // TODO: Validate manifest against a schema + + // // Validate build signature match + // if (this.#buildSignature !== manifest.buildManifest.signature) { + // throw new Error( + // `Build manifest signature ${manifest.buildManifest.signature} does not match expected ` + + // `build signature ${this.#buildSignature} for project ${this.#project.getName()}`); + // } + // return buildManifest; + // } catch (err) { + // throw new Error( + // `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { + // cause: err + // }); + // } + // } } diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 5b93cce062a..a4ec790f48f 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -2,6 +2,7 @@ import ProjectBuildContext from "./ProjectBuildContext.js"; import OutputStyleEnum from "./ProjectBuilderOutputStyle.js"; import WatchHandler from "./WatchHandler.js"; import CacheManager from "../cache/CacheManager.js"; +import {getBaseSignature} from "./getBuildSignature.js"; /** * Context of a build process @@ -75,6 +76,7 @@ class BuildContext { excludedTasks, useCache, }; + this._buildSignatureBase = getBaseSignature(this._buildConfig); this._taskRepository = taskRepository; @@ -105,11 +107,34 @@ class BuildContext { } async createProjectContext({project}) { - const projectBuildContext = await ProjectBuildContext.create(this, project); + const projectBuildContext = await ProjectBuildContext.create( + this, project, await this.getCacheManager(), this._buildSignatureBase); this._projectBuildContexts.push(projectBuildContext); return projectBuildContext; } + async createRequiredProjectContexts(requestedProjects) { + const projectBuildContexts = new Map(); + const requiredProjects = new Set(requestedProjects); + + for (const projectName of requiredProjects) { + const projectBuildContext = await this.createProjectContext({ + project: this._graph.getProject(projectName) + }); + + projectBuildContexts.set(projectName, projectBuildContext); + + // Collect all direct dependencies of the project that are required to build the project + const requiredDependencies = await projectBuildContext.getRequiredDependencies(); + + for (const depName of requiredDependencies) { + // Add dependency to list of required projects + requiredProjects.add(depName); + } + } + return projectBuildContexts; + } + async initWatchHandler(projects, updateBuildResult) { const watchHandler = new WatchHandler(this, updateBuildResult); await watchHandler.watch(projects); diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index bcb3c5f12f6..15f3567cbd2 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -2,7 +2,7 @@ import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; import ProjectBuildLogger from "@ui5/logger/internal/loggers/ProjectBuild"; import TaskUtil from "./TaskUtil.js"; import TaskRunner from "../TaskRunner.js"; -import calculateBuildSignature from "./calculateBuildSignature.js"; +import {getProjectSignature} from "./getBuildSignature.js"; import ProjectBuildCache from "../cache/ProjectBuildCache.js"; /** @@ -47,11 +47,11 @@ class ProjectBuildContext { }); } - static async create(buildContext, project) { - const buildSignature = await calculateBuildSignature(project, buildContext.getGraph(), - buildContext.getBuildConfig(), buildContext.getTaskRepository()); + static async create(buildContext, project, cacheManager, baseSignature) { + const buildSignature = getProjectSignature( + baseSignature, project, buildContext.getGraph(), buildContext.getTaskRepository()); const buildCache = await ProjectBuildCache.create( - project, buildSignature, await buildContext.getCacheManager()); + project, buildSignature, cacheManager); return new ProjectBuildContext( buildContext, project, @@ -104,6 +104,16 @@ class ProjectBuildContext { return this._buildContext.getGraph().getDependencies(projectName || this._project.getName()); } + async getRequiredDependencies() { + if (this._requiredDependencies) { + return this._requiredDependencies; + } + const taskRunner = this.getTaskRunner(); + this._requiredDependencies = Array.from(await taskRunner.getRequiredDependencies()) + .sort((a, b) => a.localeCompare(b)); + return this._requiredDependencies; + } + getResourceTagCollection(resource, tag) { if (!resource.hasProject()) { this._log.silly(`Associating resource ${resource.getPath()} with project ${this._project.getName()}`); @@ -155,14 +165,54 @@ class ProjectBuildContext { */ async requiresBuild() { if (this.#getBuildManifest()) { + // Build manifest present -> No build required return false; } - return this._buildCache.requiresBuild(); + // Check whether all required dependencies are built and collect their signatures so that + // we can validate our build cache (keyed using the project's sources and relevant dependency signatures) + const depSignatures = []; + const requiredDependencyNames = await this.getRequiredDependencies(); + for (const depName of requiredDependencyNames) { + const depCtx = this._buildContext.getBuildContext(depName); + if (!depCtx) { + throw new Error(`Unexpected missing build context for project '${depName}', dependency of ` + + `project '${this._project.getName()}'`); + } + const signature = await depCtx.getBuildResultSignature(); + if (!signature) { + // Dependency is unable to provide a signature, likely because it needs to be built itself + // Until then, we assume this project requires a build as well and return here + return true; + } + // Collect signatures + depSignatures.push(signature); + } + + return this._buildCache.requiresBuild(depSignatures); + } + + async getBuildResultSignature() { + if (await this.requiresBuild()) { + return null; + } + return await this._buildCache.getResultSignature(); + } + + async determineChangedResources() { + return this._buildCache.determineChangedResources(); } async runTasks() { - await this.getTaskRunner().runTasks(); + return await this.getTaskRunner().runTasks(); + } + + async projectResourcesChanged(changedPaths) { + return this._buildCache.projectResourcesChanged(changedPaths); + } + + async dependencyResourcesChanged(changedPaths) { + return this._buildCache.dependencyResourcesChanged(changedPaths); } #getBuildManifest() { diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 2d55d7b1237..17d1e6504dd 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -70,10 +70,11 @@ class WatchHandler extends EventEmitter { #fileChanged(project, filePath) { // Collect changes (grouped by project), then trigger callbacks const resourcePath = project.getVirtualPath(filePath); - if (!this.#sourceChanges.has(project)) { - this.#sourceChanges.set(project, new Set()); + const projectName = project.getName(); + if (!this.#sourceChanges.has(projectName)) { + this.#sourceChanges.set(projectName, new Set()); } - this.#sourceChanges.get(project).add(resourcePath); + this.#sourceChanges.get(projectName).add(resourcePath); this.#processQueue(); } @@ -113,43 +114,34 @@ class WatchHandler extends EventEmitter { async #handleResourceChanges(sourceChanges) { const dependencyChanges = new Map(); - let someProjectTasksInvalidated = false; const graph = this.#buildContext.getGraph(); - for (const [project, changedResourcePaths] of sourceChanges) { + for (const [projectName, changedResourcePaths] of sourceChanges) { // Propagate changes to dependents of the project - for (const {project: dep} of graph.traverseDependents(project.getName())) { - const depChanges = dependencyChanges.get(dep); + for (const {project: dep} of graph.traverseDependents(projectName)) { + const depChanges = dependencyChanges.get(dep.getName()); if (!depChanges) { - dependencyChanges.set(dep, new Set(changedResourcePaths)); + dependencyChanges.set(dep.getName(), new Set(changedResourcePaths)); continue; } for (const res of changedResourcePaths) { depChanges.add(res); } } + const projectBuildContext = this.#buildContext.getBuildContext(projectName); + projectBuildContext.getBuildCache() + .projectSourcesChanged(Array.from(changedResourcePaths)); } - await graph.traverseDepthFirst(async ({project}) => { - if (!sourceChanges.has(project) && !dependencyChanges.has(project)) { - return; - } - const projectSourceChanges = Array.from(sourceChanges.get(project) ?? new Set()); - const projectDependencyChanges = Array.from(dependencyChanges.get(project) ?? new Set()); - const projectBuildContext = this.#buildContext.getBuildContext(project.getName()); - const tasksInvalidated = await projectBuildContext.getBuildCache() - .resourceChanged(projectSourceChanges, projectDependencyChanges); - - if (tasksInvalidated) { - someProjectTasksInvalidated = true; - } - }); - - if (someProjectTasksInvalidated) { - this.emit("projectResourcesInvalidated"); - await this.#updateBuildResult(); - this.emit("projectResourcesUpdated"); + for (const [projectName, changedResourcePaths] of dependencyChanges) { + const projectBuildContext = this.#buildContext.getBuildContext(projectName); + projectBuildContext.getBuildCache() + .dependencyResourcesChanged(Array.from(changedResourcePaths)); } + + this.emit("projectResourcesInvalidated"); + await this.#updateBuildResult(); + this.emit("projectResourcesUpdated"); } } diff --git a/packages/project/lib/build/helpers/calculateBuildSignature.js b/packages/project/lib/build/helpers/getBuildSignature.js similarity index 60% rename from packages/project/lib/build/helpers/calculateBuildSignature.js rename to packages/project/lib/build/helpers/getBuildSignature.js index 684ea0c4d17..f3dc3bc440c 100644 --- a/packages/project/lib/build/helpers/calculateBuildSignature.js +++ b/packages/project/lib/build/helpers/getBuildSignature.js @@ -1,7 +1,11 @@ import crypto from "node:crypto"; -// Using CommonsJS require since JSON module imports are still experimental -const BUILD_CACHE_VERSION = "0"; +const BUILD_SIG_VERSION = "0"; + +export function getBaseSignature(buildConfig) { + const key = BUILD_SIG_VERSION + JSON.stringify(buildConfig); + return crypto.createHash("sha256").update(key).digest("hex"); +} /** * The build signature is calculated based on the **build configuration and environment** of a project. @@ -9,15 +13,15 @@ const BUILD_CACHE_VERSION = "0"; * The hash is represented as a hexadecimal string to allow safe usage in file names. * * @private + * @param {string} baseSignature * @param {@ui5/project/lib/Project} project The project to create the cache integrity for * @param {@ui5/project/lib/graph/ProjectGraph} graph The project graph - * @param {object} buildConfig The build configuration * @param {@ui5/builder/tasks/taskRepository} taskRepository The task repository (used to determine the effective * versions of ui5-builder and ui5-fs) */ -export default async function calculateBuildSignature(project, graph, buildConfig, taskRepository) { - const key = BUILD_CACHE_VERSION + project.getName() + - JSON.stringify(buildConfig); +export function getProjectSignature(baseSignature, project, graph, taskRepository) { + const key = baseSignature + project.getId() + JSON.stringify(project.getConfig()); + // TODO: Add signatures of relevant custom tasks // Create a hash for all metadata const hash = crypto.createHash("sha256").update(key).digest("hex"); diff --git a/packages/project/lib/specifications/Specification.js b/packages/project/lib/specifications/Specification.js index 02bdc58036e..212aa37ecdd 100644 --- a/packages/project/lib/specifications/Specification.js +++ b/packages/project/lib/specifications/Specification.js @@ -161,6 +161,10 @@ class Specification { } /* === Attributes === */ + getConfig() { + return this._config; + } + /** * Gets the ID of this specification. * From b663eaab68c1322ae5c1177527f8398b5c2b7c1c Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 13:49:20 +0100 Subject: [PATCH 075/223] refactor(project): Extract project build into own method Allows better test assertions via spies --- packages/project/lib/build/ProjectBuilder.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index c07e36a9991..b9b6310b5dd 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -295,9 +295,7 @@ class ProjectBuilder { if (alreadyBuilt.includes(projectName) || !(await projectBuildContext.requiresBuild())) { this.#log.skipProjectBuild(projectName, projectType); } else { - this.#log.startProjectBuild(projectName, projectType); - const changedResources = await projectBuildContext.runTasks(); - this.#log.endProjectBuild(projectName, projectType); + const {changedResources} = await this._buildProject(projectBuildContext); for (const pbc of queue) { // Propagate resource changes to following projects pbc.getBuildCache().dependencyResourcesChanged(changedResources); @@ -389,9 +387,7 @@ class ProjectBuilder { continue; } - this.#log.startProjectBuild(projectName, projectType); - const changedResources = await projectBuildContext.runTasks(); - this.#log.endProjectBuild(projectName, projectType); + const {changedResources} = await this._buildProject(projectBuildContext); for (const pbc of queue) { // Propagate resource changes to following projects pbc.getBuildCache().dependencyResourcesChanged(changedResources); @@ -420,6 +416,18 @@ class ProjectBuilder { await Promise.all(pWrites); } + async _buildProject(projectBuildContext) { + const project = projectBuildContext.getProject(); + const projectName = project.getName(); + const projectType = project.getType(); + + this.#log.startProjectBuild(projectName, projectType); + const changedResources = await projectBuildContext.runTasks(); + this.#log.endProjectBuild(projectName, projectType); + + return {changedResources}; + } + async _getProjectFilter({ dependencyIncludes, explicitIncludes, From 8a1aa26d8508337ae54119115a13a2ce001a119e Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 13:49:57 +0100 Subject: [PATCH 076/223] fix(project): Clear cleanup task queue --- packages/project/lib/build/helpers/ProjectBuildContext.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 15f3567cbd2..99f62073621 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -77,6 +77,7 @@ class ProjectBuildContext { await Promise.all(this._queues.cleanup.map((callback) => { return callback(force); })); + this._queues.cleanup = []; } /** From 10553fbb466c9fa483e346a15acade7b5979cec0 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 13:50:27 +0100 Subject: [PATCH 077/223] test(project): Add ProjectBuilder integration test --- .../lib/build/ProjectBuilder.integration.js | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/project/test/lib/build/ProjectBuilder.integration.js diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js new file mode 100644 index 00000000000..235d174a6c7 --- /dev/null +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -0,0 +1,84 @@ +import test from "ava"; +import sinonGlobal from "sinon"; +import {fileURLToPath} from "node:url"; +import ProjectGraph from "../../../lib/graph/ProjectGraph.js"; +import ProjectBuilder from "../../../lib/build/ProjectBuilder.js"; +import Application from "../../../lib/specifications/types/Application.js"; +import * as taskRepository from "@ui5/builder/internal/taskRepository"; + +test.beforeEach((t) => { + t.context.sinon = sinonGlobal.createSandbox(); +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); + delete process.env.UI5_DATA_DIR; +}); + +test.serial("Build application project twice without changes", async (t) => { + const {sinon} = t.context; + + process.env.UI5_DATA_DIR = getTmpPath("application.a/.ui5"); + + async function createProjectBuilder() { + const customApplicationProject = new Application(); + await customApplicationProject.init({ + "id": "application.a", + "version": "1.0.0", + "modulePath": getFixturePath("application.a"), + "configuration": { + "specVersion": "5.0", + "type": "application", + "metadata": { + "name": "application.a" + }, + "kind": "project" + } + }); + + const graph = new ProjectGraph({ + rootProjectName: customApplicationProject.getName(), + }); + graph.addProject(customApplicationProject); + graph.seal(); // Graph needs to be sealed before building + + const buildConfig = {}; + + const projectBuilder = new ProjectBuilder({ + graph, + taskRepository, + buildConfig + }); + sinon.spy(projectBuilder, "_buildProject"); + return projectBuilder; + } + + const destPath = getTmpPath("application.a/dist"); + + let projectBuilder = await createProjectBuilder(); + + // First build (with empty cache) + await projectBuilder.build({destPath}); + + t.is(projectBuilder._buildProject.callCount, 1); + t.is( + projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), + "application.a", + "application.a built in first build" + ); + + // Second build (with cache, no changes) + projectBuilder = await createProjectBuilder(); + + await projectBuilder.build({destPath}); + + t.is(projectBuilder._buildProject.callCount, 0, "No projects built in second build"); +}); + +function getFixturePath(fixtureName) { + return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); +} + +function getTmpPath(folderName) { + return fileURLToPath(new URL(`../../tmp/${folderName}`, import.meta.url)); +} From dbe3163ca9b7cb6146b8de354f047dc58b7a54e0 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 14:32:36 +0100 Subject: [PATCH 078/223] test(project): Add failing ProjectBuilder test case --- .../lib/build/ProjectBuilder.integration.js | 109 +++++++++++------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 235d174a6c7..d730aa3d134 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -1,10 +1,10 @@ import test from "ava"; import sinonGlobal from "sinon"; import {fileURLToPath} from "node:url"; -import ProjectGraph from "../../../lib/graph/ProjectGraph.js"; +import fs from "node:fs/promises"; import ProjectBuilder from "../../../lib/build/ProjectBuilder.js"; -import Application from "../../../lib/specifications/types/Application.js"; import * as taskRepository from "@ui5/builder/internal/taskRepository"; +import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; test.beforeEach((t) => { t.context.sinon = sinonGlobal.createSandbox(); @@ -15,64 +15,49 @@ test.afterEach.always((t) => { delete process.env.UI5_DATA_DIR; }); -test.serial("Build application project twice without changes", async (t) => { - const {sinon} = t.context; - - process.env.UI5_DATA_DIR = getTmpPath("application.a/.ui5"); - - async function createProjectBuilder() { - const customApplicationProject = new Application(); - await customApplicationProject.init({ - "id": "application.a", - "version": "1.0.0", - "modulePath": getFixturePath("application.a"), - "configuration": { - "specVersion": "5.0", - "type": "application", - "metadata": { - "name": "application.a" - }, - "kind": "project" - } - }); +test.serial("Build application project multiple times", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); - const graph = new ProjectGraph({ - rootProjectName: customApplicationProject.getName(), - }); - graph.addProject(customApplicationProject); - graph.seal(); // Graph needs to be sealed before building + let projectBuilder; + const destPath = fixtureTester.destPath; - const buildConfig = {}; + // #1 build (with empty cache) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath}); - const projectBuilder = new ProjectBuilder({ - graph, - taskRepository, - buildConfig - }); - sinon.spy(projectBuilder, "_buildProject"); - return projectBuilder; - } + t.is(projectBuilder._buildProject.callCount, 1); + t.is( + projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), + "application.a", + "application.a built in build #1" + ); + + // #2 build (with cache, no changes) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath}); - const destPath = getTmpPath("application.a/dist"); + t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #2"); - let projectBuilder = await createProjectBuilder(); + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added");\n`); - // First build (with empty cache) + // #3 build (with cache, with changes) + projectBuilder = await fixtureTester.createProjectBuilder(); await projectBuilder.build({destPath}); t.is(projectBuilder._buildProject.callCount, 1); t.is( projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), "application.a", - "application.a built in first build" + "application.a rebuilt in build #3" ); - // Second build (with cache, no changes) - projectBuilder = await createProjectBuilder(); - + // #4 build (with cache, no changes) + projectBuilder = await fixtureTester.createProjectBuilder(); await projectBuilder.build({destPath}); - t.is(projectBuilder._buildProject.callCount, 0, "No projects built in second build"); + t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #4"); }); function getFixturePath(fixtureName) { @@ -82,3 +67,39 @@ function getFixturePath(fixtureName) { function getTmpPath(folderName) { return fileURLToPath(new URL(`../../tmp/${folderName}`, import.meta.url)); } + +class FixtureTester { + constructor(t, fixtureName) { + this._sinon = t.context.sinon; + this._fixtureName = fixtureName; + this._initialized = false; + + // Public + this.fixturePath = getTmpPath(fixtureName); + this.destPath = getTmpPath(`${fixtureName}/dist`); + } + + async _initialize() { + if (this._initialized) { + return; + } + process.env.UI5_DATA_DIR = getTmpPath(`${this._fixtureName}/.ui5`); + await fs.cp(getFixturePath(this._fixtureName), this.fixturePath, {recursive: true}); + this._initialized = true; + } + + async createProjectBuilder() { + await this._initialize(); + const graph = await graphFromPackageDependencies({ + cwd: this.fixturePath + }); + graph.seal(); + const projectBuilder = new ProjectBuilder({ + graph, + taskRepository, + buildConfig: {} + }); + this._sinon.spy(projectBuilder, "_buildProject"); + return projectBuilder; + } +} From f5fedcbc1848b2e1c96c931e977b072e6f5cf202 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 15:27:41 +0100 Subject: [PATCH 079/223] test(project): Enhance ProjectBuilder test assertions --- .../lib/build/ProjectBuilder.integration.js | 80 +++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index d730aa3d134..83526e651b5 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -7,23 +7,41 @@ import * as taskRepository from "@ui5/builder/internal/taskRepository"; import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; test.beforeEach((t) => { - t.context.sinon = sinonGlobal.createSandbox(); + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + + t.context.logEventStub = sinon.stub(); + t.context.buildMetadataEventStub = sinon.stub(); + t.context.projectBuildMetadataEventStub = sinon.stub(); + t.context.buildStatusEventStub = sinon.stub(); + t.context.projectBuildStatusEventStub = sinon.stub(); + + process.on("ui5.log", t.context.logEventStub); + process.on("ui5.build-metadata", t.context.buildMetadataEventStub); + process.on("ui5.project-build-metadata", t.context.projectBuildMetadataEventStub); + process.on("ui5.build-status", t.context.buildStatusEventStub); + process.on("ui5.project-build-status", t.context.projectBuildStatusEventStub); }); test.afterEach.always((t) => { t.context.sinon.restore(); delete process.env.UI5_DATA_DIR; + + process.off("ui5.log", t.context.logEventStub); + process.off("ui5.build-metadata", t.context.buildMetadataEventStub); + process.off("ui5.project-build-metadata", t.context.projectBuildMetadataEventStub); + process.off("ui5.build-status", t.context.buildStatusEventStub); + process.off("ui5.project-build-status", t.context.projectBuildStatusEventStub); }); test.serial("Build application project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); - let projectBuilder; + let projectBuilder; let buildStatusEventArgs; const destPath = fixtureTester.destPath; // #1 build (with empty cache) projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath}); + await projectBuilder.build({destPath, cleanDest: false /* No clean dest needed for build #1 */}); t.is(projectBuilder._buildProject.callCount, 1); t.is( @@ -32,9 +50,15 @@ test.serial("Build application project multiple times", async (t) => { "application.a built in build #1" ); + buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + t.deepEqual( + buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], + "No 'task-skip' status in build #1" + ); + // #2 build (with cache, no changes) projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath}); + await projectBuilder.build({destPath, cleanDest: true}); t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #2"); @@ -44,7 +68,7 @@ test.serial("Build application project multiple times", async (t) => { // #3 build (with cache, with changes) projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath}); + await projectBuilder.build({destPath, cleanDest: true}); t.is(projectBuilder._buildProject.callCount, 1); t.is( @@ -53,9 +77,50 @@ test.serial("Build application project multiple times", async (t) => { "application.a rebuilt in build #3" ); - // #4 build (with cache, no changes) + buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + t.deepEqual( + buildStatusEventArgs.filter(({status}) => status === "task-skip"), [ + { + level: "info", + projectName: "application.a", + projectType: "application", + status: "task-skip", + taskName: "escapeNonAsciiCharacters", + }, + // Note: replaceCopyright task is expected to be skipped as the project + // does not define a copyright in its ui5.yaml. + { + level: "info", + projectName: "application.a", + projectType: "application", + status: "task-skip", + taskName: "replaceCopyright", + }, + { + level: "info", + projectName: "application.a", + projectType: "application", + status: "task-skip", + taskName: "enhanceManifest", + }, + { + level: "info", + projectName: "application.a", + projectType: "application", + status: "task-skip", + taskName: "generateFlexChangesBundle", + }, + ], + "'task-skip' status in build #3 for tasks that did not need to be re-executed based on changed test.js file" + ); + + // Check whether the changed file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); + + // // #4 build (with cache, no changes) projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath}); + await projectBuilder.build({destPath, cleanDest: true}); t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #4"); }); @@ -90,6 +155,7 @@ class FixtureTester { async createProjectBuilder() { await this._initialize(); + this._sinon.resetHistory(); // Reset history of spies/stubs from previous builds (e.g. process event handlers) const graph = await graphFromPackageDependencies({ cwd: this.fixturePath }); From 02c3665a34035c498310e90a16080b22fb530290 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 15:40:53 +0100 Subject: [PATCH 080/223] test(project): Add library test case for ProjectBuilder --- .../lib/build/ProjectBuilder.integration.js | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 83526e651b5..c2c05d5dc95 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -33,7 +33,7 @@ test.afterEach.always((t) => { process.off("ui5.project-build-status", t.context.projectBuildStatusEventStub); }); -test.serial("Build application project multiple times", async (t) => { +test.serial("Build application.a project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); let projectBuilder; let buildStatusEventArgs; @@ -118,7 +118,83 @@ test.serial("Build application project multiple times", async (t) => { const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); - // // #4 build (with cache, no changes) + // #4 build (with cache, no changes) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath, cleanDest: true}); + + t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #4"); +}); + +test.serial("Build library.d project multiple times", async (t) => { + const fixtureTester = new FixtureTester(t, "library.d"); + + let projectBuilder; let buildStatusEventArgs; + const destPath = fixtureTester.destPath; + + // #1 build (with empty cache) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath, cleanDest: false /* No clean dest needed for build #1 */}); + + t.is(projectBuilder._buildProject.callCount, 1); + t.is( + projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), + "library.d", + "library.d built in build #1" + ); + + buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + t.deepEqual( + buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], + "No 'task-skip' status in build #1" + ); + + // #2 build (with cache, no changes) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath, cleanDest: true}); + + t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #2"); + + // Change a source file in library.d + const changedFilePath = `${fixtureTester.fixturePath}/main/src/library/d/.library`; + await fs.writeFile( + changedFilePath, + (await fs.readFile(changedFilePath, {encoding: "utf8"})).replace( + `Some fancy copyright`, + `Some new fancy copyright` + ) + ); + + // #3 build (with cache, with changes) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath, cleanDest: true}); + + t.is(projectBuilder._buildProject.callCount, 1); + t.is( + projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), + "library.d", + "library.d rebuilt in build #3" + ); + + buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + t.deepEqual( + buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], + "No 'task-skip' status in build #3" + ); + + // Check whether the changed file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/resources/library/d/.library`, {encoding: "utf8"}); + t.true( + builtFileContent.includes(`Some new fancy copyright`), + "Build dest contains changed file content" + ); + // Check whether the updated copyright replacement took place + const builtSomeJsContent = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + t.true( + builtSomeJsContent.includes(`Some new fancy copyright`), + "Build dest contains updated copyright in some.js" + ); + + // #4 build (with cache, no changes) projectBuilder = await fixtureTester.createProjectBuilder(); await projectBuilder.build({destPath, cleanDest: true}); From 2831deb74f446d4fa4d9f66a6265d87959c15676 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 13 Jan 2026 15:58:35 +0100 Subject: [PATCH 081/223] test(project): Build dependencies in application test of ProjectBuilder --- .../lib/build/ProjectBuilder.integration.js | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index c2c05d5dc95..4f6eef2de01 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -123,6 +123,34 @@ test.serial("Build application.a project multiple times", async (t) => { await projectBuilder.build({destPath, cleanDest: true}); t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #4"); + + // #5 build (with cache, no changes, with dependencies) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}); + + t.is(projectBuilder._buildProject.callCount, 4, "Only dependency projects built in build #5"); + t.is( + projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), + "library.d", + ); + t.is( + projectBuilder._buildProject.getCall(1).args[0].getProject().getName(), + "library.a", + ); + t.is( + projectBuilder._buildProject.getCall(2).args[0].getProject().getName(), + "library.b", + ); + t.is( + projectBuilder._buildProject.getCall(3).args[0].getProject().getName(), + "library.c", + ); + + // #6 build (with cache, no changes) + projectBuilder = await fixtureTester.createProjectBuilder(); + await projectBuilder.build({destPath, cleanDest: true}); + + t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #6"); }); test.serial("Build library.d project multiple times", async (t) => { @@ -178,7 +206,7 @@ test.serial("Build library.d project multiple times", async (t) => { buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); t.deepEqual( buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], - "No 'task-skip' status in build #3" + "No 'task-skip' status in build #3" // TODO: Is this correct? ); // Check whether the changed file is in the destPath From 6df1a07a77252b7abe2919b9cce642dcbb023f1d Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 14 Jan 2026 13:52:31 +0100 Subject: [PATCH 082/223] test(project): Refactor ProjectBuilder test code --- .../lib/build/ProjectBuilder.integration.js | 325 ++++++++++-------- 1 file changed, 182 insertions(+), 143 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 4f6eef2de01..23e06f6597c 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -5,6 +5,10 @@ import fs from "node:fs/promises"; import ProjectBuilder from "../../../lib/build/ProjectBuilder.js"; import * as taskRepository from "@ui5/builder/internal/taskRepository"; import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; +import {setLogLevel} from "@ui5/logger"; + +// Ensures that all logging code paths are tested +setLogLevel("silly"); test.beforeEach((t) => { const sinon = t.context.sinon = sinonGlobal.createSandbox(); @@ -35,152 +39,143 @@ test.afterEach.always((t) => { test.serial("Build application.a project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); - - let projectBuilder; let buildStatusEventArgs; const destPath = fixtureTester.destPath; // #1 build (with empty cache) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: false /* No clean dest needed for build #1 */}); - - t.is(projectBuilder._buildProject.callCount, 1); - t.is( - projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), - "application.a", - "application.a built in build #1" - ); - - buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); - t.deepEqual( - buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], - "No 'task-skip' status in build #1" - ); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false}, + assertions: { + projects: { + "application.a": {} + } + } + }); // #2 build (with cache, no changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #2"); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); // Change a source file in application.a const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; await fs.appendFile(changedFilePath, `\ntest("line added");\n`); // #3 build (with cache, with changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 1); - t.is( - projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), - "application.a", - "application.a rebuilt in build #3" - ); - - buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); - t.deepEqual( - buildStatusEventArgs.filter(({status}) => status === "task-skip"), [ - { - level: "info", - projectName: "application.a", - projectType: "application", - status: "task-skip", - taskName: "escapeNonAsciiCharacters", - }, - // Note: replaceCopyright task is expected to be skipped as the project - // does not define a copyright in its ui5.yaml. - { - level: "info", - projectName: "application.a", - projectType: "application", - status: "task-skip", - taskName: "replaceCopyright", - }, - { - level: "info", - projectName: "application.a", - projectType: "application", - status: "task-skip", - taskName: "enhanceManifest", - }, - { - level: "info", - projectName: "application.a", - projectType: "application", - status: "task-skip", - taskName: "generateFlexChangesBundle", - }, - ], - "'task-skip' status in build #3 for tasks that did not need to be re-executed based on changed test.js file" - ); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); // Check whether the changed file is in the destPath const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); - // #4 build (with cache, no changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #4"); - - // #5 build (with cache, no changes, with dependencies) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}); - - t.is(projectBuilder._buildProject.callCount, 4, "Only dependency projects built in build #5"); - t.is( - projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), - "library.d", - ); - t.is( - projectBuilder._buildProject.getCall(1).args[0].getProject().getName(), - "library.a", - ); - t.is( - projectBuilder._buildProject.getCall(2).args[0].getProject().getName(), - "library.b", - ); - t.is( - projectBuilder._buildProject.getCall(3).args[0].getProject().getName(), - "library.c", - ); - - // #6 build (with cache, no changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #6"); + // #4 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + + // FIXME: application.a should not be rebuilt here at all. + // Currently it is rebuilt but all tasks are skipped. + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateComponentPreload", + "generateFlexChangesBundle", + "minify", + "replaceCopyright", + "replaceVersion", + ] + } + } + } + }); + + // #5 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: { + // FIXME: application.a should not be rebuilt here at all. + // Currently it is rebuilt but all tasks are skipped. + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateComponentPreload", + "generateFlexChangesBundle", + "minify", + "replaceCopyright", + "replaceVersion", + ] + } + } + } + }); + + // #6 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + // FIXME: application.a should not be rebuilt here at all. + // Currently it is rebuilt but all tasks are skipped. + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateComponentPreload", + "generateFlexChangesBundle", + "minify", + "replaceCopyright", + "replaceVersion", + ] + } + } + } + }); }); test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); - - let projectBuilder; let buildStatusEventArgs; const destPath = fixtureTester.destPath; // #1 build (with empty cache) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: false /* No clean dest needed for build #1 */}); - - t.is(projectBuilder._buildProject.callCount, 1); - t.is( - projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), - "library.d", - "library.d built in build #1" - ); - - buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); - t.deepEqual( - buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], - "No 'task-skip' status in build #1" - ); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false}, + assertions: { + projects: {"library.d": {}} + } + }); // #2 build (with cache, no changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #2"); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); // Change a source file in library.d const changedFilePath = `${fixtureTester.fixturePath}/main/src/library/d/.library`; @@ -193,21 +188,12 @@ test.serial("Build library.d project multiple times", async (t) => { ); // #3 build (with cache, with changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 1); - t.is( - projectBuilder._buildProject.getCall(0).args[0].getProject().getName(), - "library.d", - "library.d rebuilt in build #3" - ); - - buildStatusEventArgs = t.context.projectBuildStatusEventStub.args.map((args) => args[0]); - t.deepEqual( - buildStatusEventArgs.filter(({status}) => status === "task-skip"), [], - "No 'task-skip' status in build #3" // TODO: Is this correct? - ); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"library.d": {}} + } + }); // Check whether the changed file is in the destPath const builtFileContent = await fs.readFile(`${destPath}/resources/library/d/.library`, {encoding: "utf8"}); @@ -223,10 +209,12 @@ test.serial("Build library.d project multiple times", async (t) => { ); // #4 build (with cache, no changes) - projectBuilder = await fixtureTester.createProjectBuilder(); - await projectBuilder.build({destPath, cleanDest: true}); - - t.is(projectBuilder._buildProject.callCount, 0, "No projects built in build #4"); + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); }); function getFixturePath(fixtureName) { @@ -237,8 +225,13 @@ function getTmpPath(folderName) { return fileURLToPath(new URL(`../../tmp/${folderName}`, import.meta.url)); } +async function rmrf(dirPath) { + return fs.rm(dirPath, {recursive: true, force: true}); +} + class FixtureTester { constructor(t, fixtureName) { + this._t = t; this._sinon = t.context.sinon; this._fixtureName = fixtureName; this._initialized = false; @@ -253,13 +246,15 @@ class FixtureTester { return; } process.env.UI5_DATA_DIR = getTmpPath(`${this._fixtureName}/.ui5`); + await rmrf(this.fixturePath); // Clean up any previous test runs await fs.cp(getFixturePath(this._fixtureName), this.fixturePath, {recursive: true}); this._initialized = true; } - async createProjectBuilder() { + async buildProject({config = {}, assertions = {}} = {}) { await this._initialize(); - this._sinon.resetHistory(); // Reset history of spies/stubs from previous builds (e.g. process event handlers) + this._sinon.resetHistory(); + const graph = await graphFromPackageDependencies({ cwd: this.fixturePath }); @@ -269,7 +264,51 @@ class FixtureTester { taskRepository, buildConfig: {} }); - this._sinon.spy(projectBuilder, "_buildProject"); + + // Execute the build + await projectBuilder.build(config); + + // Apply assertions if provided + if (assertions) { + this._assertBuild(assertions); + } + return projectBuilder; } + + _assertBuild(assertions) { + const {projects = {}} = assertions; + const eventArgs = this._t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + + const projectsInOrder = []; + const seenProjects = new Set(); + const tasksByProject = {}; + + for (const event of eventArgs) { + if (!seenProjects.has(event.projectName)) { + projectsInOrder.push(event.projectName); + seenProjects.add(event.projectName); + } + if (!tasksByProject[event.projectName]) { + tasksByProject[event.projectName] = {executed: [], skipped: []}; + } + if (event.status === "task-skip") { + tasksByProject[event.projectName].skipped.push(event.taskName); + } else if (event.status === "task-start") { + tasksByProject[event.projectName].executed.push(event.taskName); + } + } + + // Assert projects built in order + const expectedProjects = Object.keys(projects); + this._t.deepEqual(projectsInOrder, expectedProjects); + + // Assert skipped tasks per project + for (const [projectName, expectedSkipped] of Object.entries(projects)) { + const skippedTasks = expectedSkipped.skippedTasks || []; + const actualSkipped = (tasksByProject[projectName]?.skipped || []).sort(); + const expectedArray = skippedTasks.sort(); + this._t.deepEqual(actualSkipped, expectedArray); + } + } } From 5aedd28f741a598ccc08edd510801c42d6019d2f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 14 Jan 2026 16:26:55 +0100 Subject: [PATCH 083/223] refactor(project): Refactor task resource request tracking Track requests for the project separate from the dependencies --- packages/project/lib/build/ProjectBuilder.js | 72 +- packages/project/lib/build/TaskRunner.js | 64 +- .../project/lib/build/cache/BuildTaskCache.js | 584 ++------- .../lib/build/cache/ProjectBuildCache.js | 1053 ++++++++--------- .../lib/build/cache/ResourceRequestGraph.js | 10 +- .../lib/build/cache/ResourceRequestManager.js | 533 +++++++++ .../project/lib/build/cache/StageCache.js | 14 +- .../project/lib/build/cache/index/HashTree.js | 6 +- .../lib/build/cache/index/ResourceIndex.js | 27 +- .../lib/build/helpers/ProjectBuildContext.js | 90 +- .../project/lib/build/helpers/WatchHandler.js | 6 +- .../project/lib/specifications/Project.js | 11 +- 12 files changed, 1209 insertions(+), 1261 deletions(-) create mode 100644 packages/project/lib/build/cache/ResourceRequestManager.js diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index b9b6310b5dd..9e6e79efe8e 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -236,17 +236,8 @@ class ProjectBuilder { })); const alreadyBuilt = []; - const changedDependencyResources = []; for (const projectBuildContext of queue) { - if (changedDependencyResources.length) { - // Notify build cache of changed resources from dependencies - projectBuildContext.dependencyResourcesChanged(changedDependencyResources); - } - const changedResources = await projectBuildContext.determineChangedResources(); - for (const resourcePath of changedResources) { - changedDependencyResources.push(resourcePath); - } - if (!await projectBuildContext.requiresBuild()) { + if (!await projectBuildContext.possiblyRequiresBuild()) { const projectName = projectBuildContext.getProject().getName(); alreadyBuilt.push(projectName); } @@ -292,13 +283,13 @@ class ProjectBuilder { this.#log.verbose(`Processing project ${projectName}...`); // Only build projects that are not already build (i.e. provide a matching build manifest) - if (alreadyBuilt.includes(projectName) || !(await projectBuildContext.requiresBuild())) { + if (alreadyBuilt.includes(projectName)) { this.#log.skipProjectBuild(projectName, projectType); } else { - const {changedResources} = await this._buildProject(projectBuildContext); - for (const pbc of queue) { - // Propagate resource changes to following projects - pbc.getBuildCache().dependencyResourcesChanged(changedResources); + if (await projectBuildContext.prepareProjectBuildAndValidateCache(true)) { + this.#log.skipProjectBuild(projectName, projectType); + } else { + await this._buildProject(projectBuildContext); } } if (!requestedProjects.includes(projectName)) { @@ -313,12 +304,12 @@ class ProjectBuilder { } if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_CACHE_UPDATE) { - this.#log.verbose(`Saving cache...`); - const buildManifest = await createBuildManifest( - project, - this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), - projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().writeCache(buildManifest)); + this.#log.verbose(`Triggering cache write...`); + // const buildManifest = await createBuildManifest( + // project, + // this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), + // projectBuildContext.getBuildSignature()); + pWrites.push(projectBuildContext.getBuildCache().writeCache()); } } await Promise.all(pWrites); @@ -349,7 +340,6 @@ class ProjectBuilder { async #update(projectBuildContexts, requestedProjects, fsTarget) { const queue = []; - const changedDependencyResources = []; await this._graph.traverseDepthFirst(async ({project}) => { const projectName = project.getName(); const projectBuildContext = projectBuildContexts.get(projectName); @@ -358,15 +348,6 @@ class ProjectBuilder { // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); - - if (changedDependencyResources.length) { - // Notify build cache of changed resources from dependencies - await projectBuildContext.dependencyResourcesChanged(changedDependencyResources); - } - const changedResources = await projectBuildContext.determineChangedResources(); - for (const resourcePath of changedResources) { - changedDependencyResources.push(resourcePath); - } } }); @@ -382,15 +363,18 @@ class ProjectBuilder { const projectType = project.getType(); this.#log.verbose(`Updating project ${projectName}...`); - if (!await projectBuildContext.requiresBuild()) { + let changedPaths = await projectBuildContext.prepareProjectBuildAndValidateCache(); + if (changedPaths) { this.#log.skipProjectBuild(projectName, projectType); - continue; + } else { + changedPaths = await this._buildProject(projectBuildContext); } - const {changedResources} = await this._buildProject(projectBuildContext); - for (const pbc of queue) { - // Propagate resource changes to following projects - pbc.getBuildCache().dependencyResourcesChanged(changedResources); + if (changedPaths.length) { + for (const pbc of queue) { + // Propagate resource changes to following projects + pbc.getBuildCache().dependencyResourcesChanged(changedPaths); + } } if (!requestedProjects.includes(projectName)) { // Project has not been requested @@ -406,12 +390,12 @@ class ProjectBuilder { if (process.env.UI5_BUILD_NO_CACHE_UPDATE) { continue; } - this.#log.verbose(`Updating cache...`); - const buildManifest = await createBuildManifest( - project, - this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), - projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().writeCache(buildManifest)); + this.#log.verbose(`Triggering cache write...`); + // const buildManifest = await createBuildManifest( + // project, + // this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), + // projectBuildContext.getBuildSignature()); + pWrites.push(projectBuildContext.getBuildCache().writeCache()); } await Promise.all(pWrites); } @@ -422,7 +406,7 @@ class ProjectBuilder { const projectType = project.getType(); this.#log.startProjectBuild(projectName, projectType); - const changedResources = await projectBuildContext.runTasks(); + const changedResources = await projectBuildContext.buildProject(); this.#log.endProjectBuild(projectName, projectType); return {changedResources}; diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index d9b4d227134..bc847997f57 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -81,31 +81,19 @@ class TaskRunner { } await this._addCustomTasks(); - - // Create readers for *all* dependencies - const depReaders = []; - await this._graph.traverseBreadthFirst(project.getName(), async function({project: dep}) { - if (dep.getName() === project.getName()) { - // Ignore project itself - return; - } - depReaders.push(dep.getReader()); - }); - - this._allDependenciesReader = createReaderCollection({ - name: `Dependency reader collection of project ${project.getName()}`, - readers: depReaders - }); - this._buildCache.setDependencyReader(this._allDependenciesReader); } /** * Takes a list of tasks which should be executed from the available task list of the current builder * - * @returns {Promise} Returns promise resolving once all tasks have been executed + * @returns {Promise} Resolves with list of changed resources since the last build */ async runTasks() { await this._initTasks(); + + // Ensure cached dependencies reader is initialized and up-to-date (TODO: improve this lifecycle) + await this.getDependenciesReader(this._directDependencies); + const tasksToRun = composeTaskList(Object.keys(this._tasks), this._buildConfig); const allTasks = this._taskExecutionOrder.filter((taskName) => { // There might be a numeric suffix in case a custom task is configured multiple times. @@ -141,6 +129,9 @@ class TaskRunner { * @returns {Set} Returns a set containing the names of all required direct project dependencies */ async getRequiredDependencies() { + if (this._requiredDependencies) { + return this._requiredDependencies; + } await this._initTasks(); const tasksToRun = composeTaskList(Object.keys(this._tasks), this._buildConfig); const allTasks = this._taskExecutionOrder.filter((taskName) => { @@ -155,7 +146,7 @@ class TaskRunner { const taskWithoutSuffixCounter = taskName.replace(/--\d+$/, ""); return tasksToRun.includes(taskWithoutSuffixCounter); }); - return allTasks.reduce((requiredDependencies, taskName) => { + this._requiredDependencies = allTasks.reduce((requiredDependencies, taskName) => { if (this._tasks[taskName].requiredDependencies.size) { this._log.verbose(`Task ${taskName} for project ${this._project.getName()} requires dependencies`); } @@ -164,6 +155,7 @@ class TaskRunner { } return requiredDependencies; }, new Set()); + return this._requiredDependencies; } /** @@ -198,7 +190,7 @@ class TaskRunner { options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); // TODO: Apply cache and stage handling for custom tasks as well - const cacheInfo = await this._buildCache.prepareTaskExecution(taskName); + const cacheInfo = await this._buildCache.prepareTaskExecutionAndValidateCache(taskName); if (cacheInfo === true) { this._log.skipTask(taskName); return; @@ -213,7 +205,7 @@ class TaskRunner { let dependencies; if (requiresDependencies) { - dependencies = createMonitor(this._allDependenciesReader); + dependencies = createMonitor(this._cachedDependenciesReader); params.dependencies = dependencies; } if (usingCache) { @@ -387,9 +379,9 @@ class TaskRunner { getBuildSignatureCallback, getExpectedOutputCallback, differentialUpdateCallback, - getDependenciesReader: () => { + getDependenciesReaderCb: () => { // Create the dependencies reader on-demand - return this._createDependenciesReader(requiredDependencies); + return this.getDependenciesReader(requiredDependencies); }, }), requiredDependencies @@ -422,7 +414,7 @@ class TaskRunner { } _createCustomTaskWrapper({ - project, taskUtil, getDependenciesReader, provideDependenciesReader, task, taskName, taskConfiguration + project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, task, taskName, taskConfiguration }) { return async function() { /* Custom Task Interface @@ -469,7 +461,7 @@ class TaskRunner { } if (provideDependenciesReader) { - params.dependencies = await getDependenciesReader(); + params.dependencies = await getDependenciesReaderCb(); } return taskFunction(params); }; @@ -485,13 +477,6 @@ class TaskRunner { * @returns {Promise} Resolves when task has finished */ async _executeTask(taskName, taskFunction, taskParams) { - // if (this._buildCache.isTaskCacheValid(taskName)) { - // // Immediately skip task if cache is valid - // // Continue if cache is (potentially) invalid, in which case taskFunction will - // // validate the cache thoroughly - // this._log.skipTask(taskName); - // return; - // } this._taskStart = performance.now(); await taskFunction(taskParams, this._log); if (this._log.isLevelEnabled("perf")) { @@ -499,10 +484,10 @@ class TaskRunner { } } - async _createDependenciesReader(requiredDirectDependencies) { - if (requiredDirectDependencies.size === this._directDependencies.size) { + async getDependenciesReader(dependencyNames, forceUpdate = false) { + if (!forceUpdate && dependencyNames.size === this._directDependencies.size) { // Shortcut: If all direct dependencies are required, just return the already created reader - return this._allDependenciesReader; + return this._cachedDependenciesReader; } const rootProject = this._project; @@ -510,8 +495,8 @@ class TaskRunner { const readers = []; // Add transitive dependencies to set of required dependencies - const requiredDependencies = new Set(requiredDirectDependencies); - for (const projectName of requiredDirectDependencies) { + const requiredDependencies = new Set(dependencyNames); + for (const projectName of dependencyNames) { this._graph.getTransitiveDependencies(projectName).forEach((depName) => { requiredDependencies.add(depName); }); @@ -525,10 +510,15 @@ class TaskRunner { }); // Create a reader collection for that - return createReaderCollection({ + const reader = createReaderCollection({ name: `Reduced dependency reader collection of project ${rootProject.getName()}`, readers }); + + if (dependencyNames.size === this._directDependencies.size) { + this._cachedDependenciesReader = reader; + } + return reader; } } diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 8168c27c62a..9d82c78ff50 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -1,8 +1,5 @@ -import micromatch from "micromatch"; import {getLogger} from "@ui5/logger"; -import ResourceRequestGraph, {Request} from "./ResourceRequestGraph.js"; -import ResourceIndex from "./index/ResourceIndex.js"; -import TreeRegistry from "./index/TreeRegistry.js"; +import ResourceRequestManager from "./ResourceRequestManager.js"; const log = getLogger("build:cache:BuildTaskCache"); /** @@ -11,13 +8,6 @@ const log = getLogger("build:cache:BuildTaskCache"); * @property {Set} patterns - Glob patterns used to access resources */ -/** - * @typedef {object} TaskCacheMetadata - * @property {object} requestSetGraph - Serialized resource request graph - * @property {Array} requestSetGraph.nodes - Graph nodes representing request sets - * @property {number} requestSetGraph.nextId - Next available node ID - */ - /** * Manages the build cache for a single task * @@ -39,46 +29,30 @@ export default class BuildTaskCache { #taskName; #projectName; - #resourceRequests; - #readTaskMetadataCache; - #treeRegistries = []; - #useDifferentialUpdate = true; - #hasNewOrModifiedCacheEntries = true; - - // ===== LIFECYCLE ===== + #projectRequestManager; + #dependencyRequestManager; /** * Creates a new BuildTaskCache instance * * @param {string} taskName - Name of the task this cache manages * @param {string} projectName - Name of the project this task belongs to - * @param {Function} readTaskMetadataCache - Function to read cached task metadata + * @param {object} [cachedTaskMetadata] */ - constructor(taskName, projectName, readTaskMetadataCache) { + constructor(taskName, projectName, cachedTaskMetadata) { this.#taskName = taskName; this.#projectName = projectName; - this.#readTaskMetadataCache = readTaskMetadataCache; - } - async #initResourceRequests() { - if (this.#resourceRequests) { - return; // Already initialized - } - if (!this.#readTaskMetadataCache) { + if (cachedTaskMetadata) { + this.#projectRequestManager = ResourceRequestManager.fromCache(taskName, projectName, + cachedTaskMetadata.projectRequests); + this.#dependencyRequestManager = ResourceRequestManager.fromCache(taskName, projectName, + cachedTaskMetadata.dependencyRequests); + } else { // No cache reader provided, start with empty graph - this.#resourceRequests = new ResourceRequestGraph(); - this.#hasNewOrModifiedCacheEntries = true; - return; + this.#projectRequestManager = new ResourceRequestManager(taskName, projectName); + this.#dependencyRequestManager = new ResourceRequestManager(taskName, projectName); } - - const taskMetadata = - await this.#readTaskMetadataCache(); - if (!taskMetadata) { - throw new Error(`No cached metadata found for task '${this.#taskName}' ` + - `of project '${this.#projectName}'`); - } - this.#resourceRequests = this.#restoreGraphFromCache(taskMetadata); - this.#hasNewOrModifiedCacheEntries = false; // Using cache } // ===== METADATA ACCESS ===== @@ -93,162 +67,63 @@ export default class BuildTaskCache { } hasNewOrModifiedCacheEntries() { - return this.#hasNewOrModifiedCacheEntries; + return this.#projectRequestManager.hasNewOrModifiedCacheEntries() || + this.#dependencyRequestManager.hasNewOrModifiedCacheEntries(); + } + + /** + * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources + * @param {string[]} changedProjectResourcePaths - Array of changed project resource path + * @returns {Promise} Whether any index has changed + */ + async updateProjectIndices(projectReader, changedProjectResourcePaths) { + return await this.#projectRequestManager.updateIndices(projectReader, changedProjectResourcePaths); } /** - * Updates resource indices for request sets affected by changed resources - * - * This method: - * 1. Traverses the request graph to find request sets matching changed resources - * 2. Restores missing resource indices if needed - * 3. Updates or removes resources in affected indices - * 4. Flushes all tree registries to apply batched changes * - * Changes propagate from parent to child nodes in the request graph, ensuring - * all derived request sets are updated consistently. + * Special case for dependency indices: Since dependency resources may change independently from this + * projects cache, we need to update the full index once at the beginning of every build from cache. + * This is triggered by calling this method without changedDepResourcePaths. * - * @param {Set} changedProjectResourcePaths - Set of changed project resource paths - * @param {Set} changedDepResourcePaths - Set of changed dependency resource paths - * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for accessing dependency resources - * @returns {Promise} + * @param {string[]} [changedDepResourcePaths] - Array of changed dependency resource paths + * @returns {Promise} Whether any index has changed */ - async updateIndices(changedProjectResourcePaths, changedDepResourcePaths, projectReader, dependencyReader) { - await this.#initResourceRequests(); - // Filter relevant resource changes and update the indices if necessary - const matchingRequestSetIds = []; - const updatesByRequestSetId = new Map(); - const changedProjectResourcePathsArray = Array.from(changedProjectResourcePaths); - const changedDepResourcePathsArray = Array.from(changedDepResourcePaths); - // Process all nodes, parents before children - for (const {nodeId, node, parentId} of this.#resourceRequests.traverseByDepth()) { - const addedRequests = node.getAddedRequests(); // Resource requests added at this level - let relevantUpdates; - if (addedRequests.length) { - relevantUpdates = this.#matchResourcePaths( - addedRequests, changedProjectResourcePathsArray, changedDepResourcePathsArray); - } else { - relevantUpdates = []; - } - if (parentId) { - // Include updates from parent nodes - const parentUpdates = updatesByRequestSetId.get(parentId); - if (parentUpdates && parentUpdates.length) { - relevantUpdates.push(...parentUpdates); - } - } - if (relevantUpdates.length) { - updatesByRequestSetId.set(nodeId, relevantUpdates); - matchingRequestSetIds.push(nodeId); - } - } - - const resourceCache = new Map(); - // Update matching resource indices - for (const requestSetId of matchingRequestSetIds) { - const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); - if (!resourceIndex) { - throw new Error(`Missing resource index for request set ID ${requestSetId}`); - } - - const resourcePathsToUpdate = updatesByRequestSetId.get(requestSetId); - const resourcesToUpdate = []; - const removedResourcePaths = []; - for (const resourcePath of resourcePathsToUpdate) { - let resource; - if (resourceCache.has(resourcePath)) { - resource = resourceCache.get(resourcePath); - } else { - if (changedDepResourcePaths.has(resourcePath)) { - resource = await dependencyReader.byPath(resourcePath); - } else { - resource = await projectReader.byPath(resourcePath); - } - resourceCache.set(resourcePath, resource); - } - if (resource) { - resourcesToUpdate.push(resource); - } else { - // Resource has been removed - removedResourcePaths.push(resourcePath); - } - } - if (removedResourcePaths.length) { - await resourceIndex.removeResources(removedResourcePaths); - } - if (resourcesToUpdate.length) { - await resourceIndex.upsertResources(resourcesToUpdate); - } - } - if (this.#useDifferentialUpdate) { - return await this.#flushTreeChangesWithDiff(changedProjectResourcePaths); + async updateDependencyIndices(dependencyReader, changedDepResourcePaths) { + if (changedDepResourcePaths) { + return await this.#dependencyRequestManager.updateIndices(dependencyReader, changedDepResourcePaths); } else { - return await this.#flushTreeChanges(changedProjectResourcePaths); + return await this.#dependencyRequestManager.refreshIndices(dependencyReader); } } /** - * Matches changed resources against a set of requests + * Gets all project index signatures for this task * - * Tests each request against the changed resource paths using exact path matching - * for 'path'/'dep-path' requests and glob pattern matching for 'patterns'/'dep-patterns' requests. + * Returns signatures from all recorded project-request sets. Each signature represents + * a unique combination of resources, belonging to the current project, that were accessed + * during task execution. This can be used to form a cache keys for restoring cached task results. * - * @private - * @param {Request[]} resourceRequests - Array of resource requests to match against - * @param {string[]} projectResourcePaths - Changed project resource paths - * @param {string[]} dependencyResourcePaths - Changed dependency resource paths - * @returns {string[]} Array of matched resource paths - * @throws {Error} If an unknown request type is encountered + * @returns {Promise} Array of signature strings + * @throws {Error} If resource index is missing for any request set */ - #matchResourcePaths(resourceRequests, projectResourcePaths, dependencyResourcePaths) { - const matchedResources = []; - for (const {type, value} of resourceRequests) { - switch (type) { - case "path": - if (projectResourcePaths.includes(value)) { - matchedResources.push(value); - } - break; - case "patterns": - matchedResources.push(...micromatch(projectResourcePaths, value)); - break; - case "dep-path": - if (dependencyResourcePaths.includes(value)) { - matchedResources.push(value); - } - break; - case "dep-patterns": - matchedResources.push(...micromatch(dependencyResourcePaths, value)); - break; - default: - throw new Error(`Unknown request type: ${type}`); - } - } - return matchedResources; + getProjectIndexSignatures() { + return this.#projectRequestManager.getIndexSignatures(); } /** - * Gets all possible stage signatures for this task + * Gets all dependency index signatures for this task * - * Returns signatures from all recorded request sets. Each signature represents - * a unique combination of resources that were accessed during task execution. - * Used to look up cached build stages. + * Returns signatures from all recorded dependency-request sets. Each signature represents + * a unique combination of resources, belonging to all dependencies of the current project, that were accessed + * during task execution. This can be used to form a cache keys for restoring cached task results. * - * @returns {Promise} Array of stage signature strings + * @returns {Promise} Array of signature strings * @throws {Error} If resource index is missing for any request set */ - async getPossibleStageSignatures() { - await this.#initResourceRequests(); - const requestSetIds = this.#resourceRequests.getAllNodeIds(); - const signatures = requestSetIds.map((requestSetId) => { - const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); - if (!resourceIndex) { - throw new Error(`Resource index missing for request set ID ${requestSetId}`); - } - return resourceIndex.getSignature(); - }); - return signatures; + getDependencyIndexSignatures() { + return this.#dependencyRequestManager.getIndexSignatures(); } /** @@ -264,291 +139,33 @@ export default class BuildTaskCache { * The signature uniquely identifies the set of resources accessed and their * content, enabling cache lookup for previously executed task results. * - * @param {ResourceRequests} projectRequests - Project resource requests (paths and patterns) - * @param {ResourceRequests} [dependencyRequests] - Dependency resource requests (paths and patterns) + * @param {ResourceRequests} projectRequestRecording - Project resource requests (paths and patterns) + * @param {ResourceRequests|undefined} dependencyRequestRecording - Dependency resource requests * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for accessing dependency resources * @returns {Promise} Signature hash string of the resource index */ - async calculateSignature(projectRequests, dependencyRequests, projectReader, dependencyReader) { - await this.#initResourceRequests(); - const requests = []; - for (const pathRead of projectRequests.paths) { - requests.push(new Request("path", pathRead)); - } - for (const patterns of projectRequests.patterns) { - requests.push(new Request("patterns", patterns)); - } - if (dependencyRequests) { - for (const pathRead of dependencyRequests.paths) { - requests.push(new Request("dep-path", pathRead)); - } - for (const patterns of dependencyRequests.patterns) { - requests.push(new Request("dep-patterns", patterns)); - } - } - // Try to find an existing request set that we can reuse - let setId = this.#resourceRequests.findExactMatch(requests); - let resourceIndex; - if (setId) { - // Reuse existing resource index. - // Note: This index has already been updated before the task executed, so no update is necessary here - resourceIndex = this.#resourceRequests.getMetadata(setId).resourceIndex; + async recordRequests(projectRequestRecording, dependencyRequestRecording, projectReader, dependencyReader) { + const { + setId: projectReqSetId, signature: projectReqSignature + } = await this.#projectRequestManager.addRequests(projectRequestRecording, projectReader); + + let dependencyReqSignature; + if (dependencyRequestRecording) { + const { + setId: depReqSetId, signature: depReqSignature + } = await this.#dependencyRequestManager.addRequests(dependencyRequestRecording, dependencyReader); + + this.#projectRequestManager.addAffiliatedRequestSet(projectReqSetId, depReqSetId); + dependencyReqSignature = depReqSignature; } else { - // New request set, check whether we can create a delta - const metadata = {}; // Will populate with resourceIndex below - setId = this.#resourceRequests.addRequestSet(requests, metadata); - - const requestSet = this.#resourceRequests.getNode(setId); - const parentId = requestSet.getParentId(); - if (parentId) { - const {resourceIndex: parentResourceIndex} = this.#resourceRequests.getMetadata(parentId); - // Add resources from delta to index - const addedRequests = requestSet.getAddedRequests(); - const resourcesToAdd = - await this.#getResourcesForRequests(addedRequests, projectReader, dependencyReader); - if (!resourcesToAdd.length) { - throw new Error(`Unexpected empty added resources for request set ID ${setId} ` + - `of task '${this.#taskName}' of project '${this.#projectName}'`); - } - log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + - `created derived resource index for request set ID ${setId} ` + - `based on parent ID ${parentId} with ${resourcesToAdd.length} additional resources`); - resourceIndex = await parentResourceIndex.deriveTree(resourcesToAdd); - } else { - const resourcesRead = - await this.#getResourcesForRequests(requests, projectReader, dependencyReader); - resourceIndex = await ResourceIndex.create(resourcesRead, this.#newTreeRegistry()); - } - metadata.resourceIndex = resourceIndex; + dependencyReqSignature = "X"; // No dependencies accessed } - return resourceIndex.getSignature(); - } - - /** - * Creates and registers a new tree registry - * - * Tree registries enable batched updates across multiple derived trees, - * improving performance when multiple indices share common subtrees. - * - * @private - * @returns {TreeRegistry} New tree registry instance - */ - #newTreeRegistry() { - const registry = new TreeRegistry(); - this.#treeRegistries.push(registry); - return registry; + return [projectReqSignature, dependencyReqSignature]; } - /** - * Flushes all tree registries to apply batched updates - * - * Commits all pending tree modifications across all registries in parallel. - * Must be called after operations that schedule updates via registries. - * - * @private - * @returns {Promise} Object containing sets of added, updated, and removed resource paths - */ - async #flushTreeChanges() { - return await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); - } - - /** - * Flushes all tree registries to apply batched updates - * - * Commits all pending tree modifications across all registries in parallel. - * Must be called after operations that schedule updates via registries. - * - * @param {Set} projectResourcePaths Set of changed project resource paths - * @private - * @returns {Promise} Object containing sets of added, updated, and removed resource paths - */ - async #flushTreeChangesWithDiff(projectResourcePaths) { - const requestSetIds = this.#resourceRequests.getAllNodeIds(); - const trees = new Map(); - // Record current signatures and create mapping between trees and request sets - requestSetIds.map((requestSetId) => { - const {resourceIndex} = this.#resourceRequests.getMetadata(requestSetId); - if (!resourceIndex) { - throw new Error(`Resource index missing for request set ID ${requestSetId}`); - } - trees.set(resourceIndex.getTree(), { - requestSetId, - signature: resourceIndex.getSignature(), - }); - }); - - let greatestNumberOfChanges = 0; - let relevantTree; - let relevantStats; - const res = await this.#flushTreeChanges(); - - // Based on the returned stats, find the tree with the greatest difference - // If none of the updated trees lead to a valid cache, this tree can be used to execute a differential - // build (assuming there's a cache for its previous signature) - for (const {treeStats} of res) { - for (const [tree, stats] of treeStats) { - if (stats.removed.length > 0) { - // If the update process removed resources from that tree, this means that using it in a - // differential build might lead to stale removed resources - return; - } - const numberOfChanges = stats.added.length + stats.updated.length; - if (numberOfChanges > greatestNumberOfChanges) { - greatestNumberOfChanges = numberOfChanges; - relevantTree = tree; - relevantStats = stats; - } - } - } - - if (!relevantTree) { - return; - } - this.#hasNewOrModifiedCacheEntries = true; - - // Update signatures for affected request sets - const {requestSetId, signature: originalSignature} = trees.get(relevantTree); - const newSignature = relevantTree.getRootHash(); - log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + - `updated resource index for request set ID ${requestSetId} ` + - `from signature ${originalSignature} ` + - `to ${newSignature}`); - - const changedProjectResourcePaths = new Set(); - const changedDependencyResourcePaths = new Set(); - for (const path of relevantStats.added) { - if (projectResourcePaths.has(path)) { - changedProjectResourcePaths.add(path); - } else { - changedDependencyResourcePaths.add(path); - } - } - for (const path of relevantStats.updated) { - if (projectResourcePaths.has(path)) { - changedProjectResourcePaths.add(path); - } else { - changedDependencyResourcePaths.add(path); - } - } - - return { - originalSignature, - newSignature, - changedProjectResourcePaths, - changedDependencyResourcePaths, - }; - } - - /** - * Retrieves resources for a set of resource requests - * - * Processes different request types: - * - 'path': Retrieves single resource by path from project reader - * - 'patterns': Retrieves resources matching glob patterns from project reader - * - 'dep-path': Retrieves single resource by path from dependency reader - * - 'dep-patterns': Retrieves resources matching glob patterns from dependency reader - * - * @private - * @param {Request[]|Array<{type: string, value: string|string[]}>} resourceRequests - Resource requests to process - * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for project resources - * @param {module:@ui5/fs.AbstractReader} dependencyReder - Reader for dependency resources - * @returns {Promise>} Iterator of retrieved resources - * @throws {Error} If an unknown request type is encountered - */ - async #getResourcesForRequests(resourceRequests, projectReader, dependencyReder) { - const resourcesMap = new Map(); - for (const {type, value} of resourceRequests) { - switch (type) { - case "path": { - const resource = await projectReader.byPath(value); - if (resource) { - resourcesMap.set(value, resource); - } - break; - } - case "patterns": { - const matchedResources = await projectReader.byGlob(value); - for (const resource of matchedResources) { - resourcesMap.set(resource.getOriginalPath(), resource); - } - break; - } - case "dep-path": { - const resource = await dependencyReder.byPath(value); - if (resource) { - resourcesMap.set(value, resource); - } - break; - } - case "dep-patterns": { - const matchedResources = await dependencyReder.byGlob(value); - for (const resource of matchedResources) { - resourcesMap.set(resource.getOriginalPath(), resource); - } - break; - } - default: - throw new Error(`Unknown request type: ${type}`); - } - } - return Array.from(resourcesMap.values()); - } - - /** - * Checks if changed resources match this task's tracked resources - * - * This is a fast check that determines if the task *might* be invalidated - * based on path matching and glob patterns. - * - * @param {string[]} projectResourcePaths - Changed project resource paths - * @param {string[]} dependencyResourcePaths - Changed dependency resource paths - * @returns {boolean} True if any changed resources match this task's tracked resources - */ - async matchesChangedResources(projectResourcePaths, dependencyResourcePaths) { - await this.#initResourceRequests(); - const resourceRequests = this.#resourceRequests.getAllRequests(); - return resourceRequests.some(({type, value}) => { - if (type === "path") { - return projectResourcePaths.includes(value); - } - if (type === "patterns") { - return micromatch(projectResourcePaths, value).length > 0; - } - if (type === "dep-path") { - return dependencyResourcePaths.includes(value); - } - if (type === "dep-patterns") { - return micromatch(dependencyResourcePaths, value).length > 0; - } - throw new Error(`Unknown request type: ${type}`); - }); - } - - async isAffectedByProjectChanges(changedPaths) { - await this.#initResourceRequests(); - const resourceRequests = this.#resourceRequests.getAllRequests(); - return resourceRequests.some(({type, value}) => { - if (type === "path") { - return changedPaths.includes(value); - } - if (type === "patterns") { - return micromatch(changedPaths, value).length > 0; - } - }); - } - - async isAffectedByDependencyChanges(changedPaths) { - await this.#initResourceRequests(); - const resourceRequests = this.#resourceRequests.getAllRequests(); - return resourceRequests.some(({type, value}) => { - if (type === "dep-path") { - return changedPaths.includes(value); - } - if (type === "dep-patterns") { - return micromatch(changedPaths, value).length > 0; - } - }); + findDelta() { + // TODO: Implement } /** @@ -559,71 +176,10 @@ export default class BuildTaskCache { * * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph */ - toCacheObject() { - if (!this.#resourceRequests) { - throw new Error("BuildTaskCache#toCacheObject: Resource requests not initialized for task " + - `'${this.#taskName}' of project '${this.#projectName}'`); - } - const rootIndices = []; - const deltaIndices = []; - for (const {nodeId, parentId} of this.#resourceRequests.traverseByDepth()) { - const {resourceIndex} = this.#resourceRequests.getMetadata(nodeId); - if (!resourceIndex) { - throw new Error(`Missing resource index for node ID ${nodeId}`); - } - if (!parentId) { - rootIndices.push({ - nodeId, - resourceIndex: resourceIndex.toCacheObject(), - }); - } else { - const {resourceIndex: rootResourceIndex} = this.#resourceRequests.getMetadata(parentId); - if (!rootResourceIndex) { - throw new Error(`Missing root resource index for parent ID ${parentId}`); - } - // Store the metadata for all added resources. Note: Those resources might not be available - // in the current tree. In that case we store an empty array. - const addedResourceIndex = resourceIndex.getAddedResourceIndex(rootResourceIndex); - deltaIndices.push({ - nodeId, - addedResourceIndex, - }); - } - } + toCacheObjects() { return { - requestSetGraph: this.#resourceRequests.toCacheObject(), - rootIndices, - deltaIndices, + projectRequests: this.#projectRequestManager.toCacheObject(), + dependencyRequests: this.#dependencyRequestManager.toCacheObject(), }; } - - #restoreGraphFromCache({requestSetGraph, rootIndices, deltaIndices}) { - const resourceRequests = ResourceRequestGraph.fromCacheObject(requestSetGraph); - const registries = new Map(); - // Restore root resource indices - for (const {nodeId, resourceIndex: serializedIndex} of rootIndices) { - const metadata = resourceRequests.getMetadata(nodeId); - const registry = this.#newTreeRegistry(); - registries.set(nodeId, registry); - metadata.resourceIndex = ResourceIndex.fromCache(serializedIndex, registry); - } - // Restore delta resource indices - if (deltaIndices) { - for (const {nodeId, addedResourceIndex} of deltaIndices) { - const node = resourceRequests.getNode(nodeId); - const {resourceIndex: parentResourceIndex} = resourceRequests.getMetadata(node.getParentId()); - const registry = registries.get(node.getParentId()); - if (!registry) { - throw new Error(`Missing tree registry for parent of node ID ${nodeId} of task ` + - `'${this.#taskName}' of project '${this.#projectName}'`); - } - const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex); - - resourceRequests.setMetadata(nodeId, { - resourceIndex, - }); - } - } - return resourceRequests; - } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 46971cebbb2..469c748ac6b 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -11,6 +11,15 @@ import ResourceIndex from "./index/ResourceIndex.js"; import {firstTruthy} from "./utils.js"; const log = getLogger("build:cache:ProjectBuildCache"); +export const CACHE_STATES = Object.freeze({ + INITIALIZING: "initializing", + INITIALIZED: "initialized", + EMPTY: "empty", + STALE: "stale", + FRESH: "fresh", + DIRTY: "dirty", +}); + /** * @typedef {object} StageMetadata * @property {Object} resourceMetadata @@ -19,7 +28,7 @@ const log = getLogger("build:cache:ProjectBuildCache"); /** * @typedef {object} StageCacheEntry * @property {@ui5/fs/AbstractReader} stage - Reader for the cached stage - * @property {Set} writtenResourcePaths - Set of resource paths written by the task + * @property {string[]} writtenResourcePaths - Set of resource paths written by the task */ export default class ProjectBuildCache { @@ -28,25 +37,21 @@ export default class ProjectBuildCache { #project; #buildSignature; - // #buildManifest; #cacheManager; #currentProjectReader; - #dependencyReader; + #currentDependencyReader; #sourceIndex; #cachedSourceSignature; - #resultIndex; + #currentDependencySignatures = new Map(); #cachedResultSignature; #currentResultSignature; - #usingResultStage = false; - // Pending changes - #changedProjectSourcePaths = new Set(); - #changedProjectResourcePaths = new Set(); - #changedDependencyResourcePaths = new Set(); - #changedResultResourcePaths = new Set(); + #changedProjectSourcePaths = []; + #changedDependencyResourcePaths = []; + #writtenResultResourcePaths = []; - #invalidatedTasks = new Map(); + #cacheState = CACHE_STATES.INITIALIZING; /** * Creates a new ProjectBuildCache instance @@ -77,223 +82,161 @@ export default class ProjectBuildCache { */ static async create(project, buildSignature, cacheManager) { const cache = new ProjectBuildCache(project, buildSignature, cacheManager); - await cache.#init(); + await cache.#initSourceIndex(); return cache; } /** - * Initializes the cache by loading resource index, build manifest, and checking cache validity + * Sets the dependency reader for accessing dependency resources * - * @private - * @returns {Promise} + * The dependency reader is used by tasks to access resources from project + * dependencies. Must be set before tasks that require dependencies are executed. + * + * @param {@ui5/fs/AbstractReader} dependencyReader - Reader for dependency resources + * @param {boolean} [forceDependencyUpdate=false] + * @returns {Promise} True if cache is fresh and can be fully utilized, false otherwise */ - async #init() { - // this.#buildManifest = await this.#loadBuildManifest(); - // this.#sourceIndex = await this.#initResourceIndex(); - // const hasIndexCache = await this.#loadIndexCache(); - // const requiresDepdendencyResources = true; // TODO: Determine dynamically using task caches - // this.#requiresInitialBuild = !hasIndexCache || requiresDepdendencyResources; - } + async prepareProjectBuildAndValidateCache(dependencyReader, forceDependencyUpdate = false) { + this.#currentProjectReader = this.#project.getReader(); + this.#currentDependencyReader = dependencyReader; - /** - * Determines changed resources since last build - * - * This is expected to be the first method called on the cache. - * Hence it will perform some initialization and deserialization tasks as needed. - */ - async determineChangedResources() { - // TODO: Start detached initializations in constructor and await them here? - let changedSourcePaths; - if (!this.#sourceIndex) { - changedSourcePaths = await this.#initSourceIndex(); - for (const resourcePath of changedSourcePaths) { - this.#changedProjectSourcePaths.add(resourcePath); - } - } else if (this.#changedProjectSourcePaths.size) { - changedSourcePaths = await this._updateSourceIndex(this.#changedProjectSourcePaths); - } else { - changedSourcePaths = []; + if (this.#cacheState === CACHE_STATES.EMPTY) { + log.verbose(`Project ${this.#project.getName()} has empty cache, skipping change processing.`); + return false; } - - if (!this.#resultIndex) { - await this.#initResultIndex(); + if (forceDependencyUpdate) { + await this.#updateDependencyIndices(dependencyReader); } - - await this.#flushPendingInputChanges(); - return changedSourcePaths; + await this.#flushPendingChanges(); + const changedResources = await this.#findResultCache(); + return changedResources; } /** - * Determines whether a rebuild is needed. - * - * A rebuild is required if: - * - No task cache exists - * - Any tasks have been invalidated - * - Initial build is required (e.g., cache couldn't be loaded) - * - * @param {string[]} dependencySignatures - Sorted by name of the dependency project - * @returns {boolean} True if rebuild is needed, false if cache can be fully utilized - */ - async requiresBuild(dependencySignatures) { - if (this.#invalidatedTasks.size > 0) { - this.#usingResultStage = false; - return true; + * Processes changed resources since last build, updating indices and invalidating tasks as needed + */ + async #flushPendingChanges() { + if (this.#changedProjectSourcePaths.length === 0 && + this.#changedDependencyResourcePaths.length === 0) { + return; } - - if (this.#usingResultStage && this.#invalidatedTasks.size === 0) { - return false; + let sourceIndexChanged = false; + if (this.#changedProjectSourcePaths.length) { + // Update source index so we can use the signature later as part of the result stage signature + sourceIndexChanged = await this.#updateSourceIndex(this.#changedProjectSourcePaths); } - if (await this.#hasValidResultCache(dependencySignatures)) { - return false; + let depIndicesChanged = false; + if (this.#changedDependencyResourcePaths.length) { + await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { + const changed = await taskCache + .updateDependencyIndices(this.#currentDependencyReader, this.#changedDependencyResourcePaths); + if (changed) { + depIndicesChanged = true; + } + })); } - return true; - } - - async getResultSignature() { - // Do not include dependency signatures here. They are not relevant to consumers of this project and would - // unnecessarily invalidate their caches. - return this.#resultIndex.getSignature(); - } - /** - * Initializes the resource index from cache or creates a new one - * - * This method attempts to load a cached resource index. If found, it validates - * the index against current source files and invalidates affected tasks if - * resources have changed. If no cache exists, creates a fresh index. - * - * @private - * @throws {Error} If cached index signature doesn't match computed signature - */ - async #initSourceIndex() { - const sourceReader = this.#project.getSourceReader(); - const [resources, indexCache] = await Promise.all([ - await sourceReader.byGlob("/**/*"), - await this.#cacheManager.readIndexCache(this.#project.getId(), this.#buildSignature, "source"), - ]); - if (indexCache) { - log.verbose(`Using cached resource index for project ${this.#project.getName()}`); - // Create and diff resource index - const {resourceIndex, changedPaths} = - await ResourceIndex.fromCacheWithDelta(indexCache, resources); - // Import task caches - - for (const taskName of indexCache.taskList) { - this.#taskCache.set(taskName, - new BuildTaskCache(taskName, this.#project.getName(), - this.#createBuildTaskCacheMetadataReader(taskName))); - } - if (changedPaths.length) { - // Invalidate tasks based on changed resources - // Note: If the changed paths don't affect any task, the index cache still can't be used due to the - // root hash mismatch. - // Since no tasks have been invalidated, a rebuild is still necessary in this case, so that - // each task can find and use its individual stage cache. - // Hence requiresInitialBuild will be set to true in this case (and others. - const tasksInvalidated = await this._invalidateTasks(changedPaths, []); - if (!tasksInvalidated) { - this.#cachedSourceSignature = resourceIndex.getSignature(); - } - // for (const resourcePath of changedPaths) { - // this.#changedProjectResourcePaths.add(resourcePath); - // } - } else if (indexCache.indexTree.root.hash !== resourceIndex.getSignature()) { - // Validate index signature matches with cached signature - throw new Error( - `Resource index signature mismatch for project ${this.#project.getName()}: ` + - `expected ${indexCache.indexTree.root.hash}, got ${resourceIndex.getSignature()}`); - } else { - log.verbose( - `Resource index signature for project ${this.#project.getName()} matches cached signature: ` + - `${resourceIndex.getSignature()}`); - this.#cachedSourceSignature = resourceIndex.getSignature(); - } - this.#sourceIndex = resourceIndex; - return changedPaths; + if (sourceIndexChanged || depIndicesChanged) { + // Relevant resources have changed, mark the cache as dirty + this.#cacheState = CACHE_STATES.DIRTY; } else { - // No index cache found, create new index - this.#sourceIndex = await ResourceIndex.create(resources); - return []; + log.verbose(`No relevant resource changes detected for project ${this.#project.getName()}`); } + + // Reset pending changes + this.#changedProjectSourcePaths = []; + this.#changedDependencyResourcePaths = []; } - async _updateSourceIndex(resourcePaths) { - if (resourcePaths.size === 0) { - return []; - } - const sourceReader = this.#project.getSourceReader(); - const resources = await Promise.all(Array.from(resourcePaths).map(async (resourcePath) => { - const resource = await sourceReader.byPath(resourcePath); - if (!resource) { - throw new Error( - `Failed to update source index for project ${this.#project.getName()}: ` + - `resource at path ${resourcePath} not found in source reader`); + async #updateDependencyIndices(dependencyReader) { + let depIndicesChanged = false; + await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { + const changed = await taskCache.updateDependencyIndices(this.#currentDependencyReader); + if (changed) { + depIndicesChanged = true; } - return resource; })); - const res = await this.#sourceIndex.upsertResources(resources); - return [...res.added, ...res.updated]; - } - - async #initResultIndex() { - const indexCache = await this.#cacheManager.readIndexCache( - this.#project.getId(), this.#buildSignature, "result"); - - if (indexCache) { - log.verbose(`Using cached result resource index for project ${this.#project.getName()}`); - this.#resultIndex = await ResourceIndex.fromCache(indexCache); - this.#cachedResultSignature = this.#resultIndex.getSignature(); - } else { - this.#resultIndex = await ResourceIndex.create([]); + if (depIndicesChanged) { + // Relevant resources have changed, mark the cache as dirty + this.#cacheState = CACHE_STATES.DIRTY; } + // Reset pending dependency changes since indices are fresh now anyways + this.#changedDependencyResourcePaths = []; } - #getResultStageSignature(sourceSignature, dependencySignatures) { - // Different from the project cache's "result signature", the "result stage signature" includes the - // signatures of dependencies, since they possibly affect the result stage's content. - const stageSignature = `${sourceSignature}|${dependencySignatures.join("|")}`; - return crypto.createHash("sha256").update(stageSignature).digest("hex"); + isFresh() { + return this.#cacheState === CACHE_STATES.FRESH; } /** - * Loads the cached result stage from persistent storage + * Loads a cached result stage from persistent storage if available * * Attempts to load a cached result stage using the resource index signature. * If found, creates a reader for the cached stage and sets it as the project's * result stage. * - * @param {string[]} dependencySignatures - * @private - * @returns {Promise} True if cache was loaded successfully, false otherwise + * @returns {Promise} */ - async #hasValidResultCache(dependencySignatures) { - const stageSignature = this.#getResultStageSignature(this.#sourceIndex.getSignature(), dependencySignatures); - if (this.#currentResultSignature === stageSignature) { - // log.verbose( - // `Project ${this.#project.getName()} result stage signature unchanged: ${stageSignature}`); - // TODO: Requires setResultStage again? - return this.#usingResultStage; + async #findResultCache() { + if (this.#cacheState === CACHE_STATES.STALE && this.#currentResultSignature) { + log.verbose(`Project ${this.#project.getName()} cache state is stale but no changes have been detected. ` + + `Continuing with current result stage: ${this.#currentResultSignature}`); + this.#cacheState = CACHE_STATES.FRESH; + return []; } - this.#currentResultSignature = stageSignature; - const stageId = "result"; - log.verbose(`Project ${this.#project.getName()} resource index signature: ${stageSignature}`); - const stageCache = await this.#cacheManager.readStageCache( - this.#project.getId(), this.#buildSignature, stageId, stageSignature); + if (![CACHE_STATES.STALE, CACHE_STATES.DIRTY, CACHE_STATES.INITIALIZED].includes(this.#cacheState)) { + log.verbose(`Project ${this.#project.getName()} cache state is ${this.#cacheState}, ` + + `skipping result cache validation.`); + return; + } + const stageSignatures = this.#getPossibleResultStageSignatures(); + if (stageSignatures.includes(this.#currentResultSignature)) { + log.verbose( + `Project ${this.#project.getName()} result stage signature unchanged: ${this.#currentResultSignature}`); + this.#cacheState = CACHE_STATES.FRESH; + return []; + } + const stageCache = await this.#findStageCache("result", stageSignatures); if (!stageCache) { log.verbose( - `No cached stage found for project ${this.#project.getName()} with index signature ${stageSignature}`); - return false; + `No cached stage found for project ${this.#project.getName()}. Searching with ` + + `${stageSignatures.length} possible signatures.`); + // Cache state remains dirty + // this.#cacheState = CACHE_STATES.EMPTY; + return; } + const {stage, signature, writtenResourcePaths} = stageCache; log.verbose( - `Using cached result stage for project ${this.#project.getName()} with index signature ${stageSignature}`); - const reader = await this.#createReaderForStageCache( - stageId, stageSignature, stageCache.resourceMetadata); - this.#project.setResultStage(reader); + `Using cached result stage for project ${this.#project.getName()} with index signature ${signature}`); + this.#currentResultSignature = signature; + this.#cachedResultSignature = signature; + this.#project.setResultStage(stage); this.#project.useResultStage(); - this.#usingResultStage = true; - return true; + this.#cacheState = CACHE_STATES.FRESH; + return writtenResourcePaths; + } + + #getPossibleResultStageSignatures() { + const projectSourceSignature = this.#sourceIndex.getSignature(); + + const taskDependencySignatures = []; + for (const taskCache of this.#taskCache.values()) { + taskDependencySignatures.push(taskCache.getDependencyIndexSignatures()); + } + const dependencySignaturesCombinations = cartesianProduct(taskDependencySignatures); + + return dependencySignaturesCombinations.map((dependencySignatures) => { + const combinedDepSignature = createDependencySignature(dependencySignatures); + return createStageSignature(projectSourceSignature, combinedDepSignature); + }); + } + + #getResultStageSignature() { + const projectSourceSignature = this.#sourceIndex.getSignature(); + const combinedDepSignature = createDependencySignature(Array.from(this.#currentDependencySignatures.values())); + return createStageSignature(projectSourceSignature, combinedDepSignature); } // ===== TASK MANAGEMENT ===== @@ -310,7 +253,7 @@ export default class ProjectBuildCache { * @param {string} taskName - Name of the task to prepare * @returns {Promise} True or object if task can use cache, false otherwise */ - async prepareTaskExecution(taskName) { + async prepareTaskExecutionAndValidateCache(taskName) { const stageName = this.#getStageNameForTask(taskName); const taskCache = this.#taskCache.get(taskName); // Store current project reader (= state of the previous stage) for later use (e.g. in recordTaskResult) @@ -318,59 +261,84 @@ export default class ProjectBuildCache { // Switch project to new stage this.#project.useStage(stageName); log.verbose(`Preparing task execution for task ${taskName} in project ${this.#project.getName()}...`); - if (taskCache) { - let deltaInfo; - if (this.#invalidatedTasks.has(taskName)) { - const invalidationInfo = - this.#invalidatedTasks.get(taskName); - log.verbose(`Task cache for task ${taskName} has been invalidated, updating indices ` + - `with ${invalidationInfo.changedProjectResourcePaths.size} changed project resource paths and ` + - `${invalidationInfo.changedDependencyResourcePaths.size} changed dependency resource paths...`); - deltaInfo = await taskCache.updateIndices( - invalidationInfo.changedProjectResourcePaths, - invalidationInfo.changedDependencyResourcePaths, - this.#currentProjectReader, this.#dependencyReader); - } // else: Index will be created upon task completion - - // After index update, try to find cached stages for the new signatures - const stageSignatures = await taskCache.getPossibleStageSignatures(); - const stageCache = await this.#findStageCache(stageName, stageSignatures); - if (stageCache) { - const stageChanged = this.#project.setStage(stageName, stageCache.stage); - - // Task can be skipped, use cached stage as project reader - if (this.#invalidatedTasks.has(taskName)) { - this.#invalidatedTasks.delete(taskName); - } + if (!taskCache) { + log.verbose(`No task cache found`); + return false; + } - if (!stageChanged && stageCache.writtenResourcePaths.size) { - // Invalidate following tasks - this.#invalidateFollowingTasks(taskName, Array.from(stageCache.writtenResourcePaths)); - } - return true; // No need to execute the task - } else if (deltaInfo) { - log.verbose(`No cached stage found for task ${taskName} in project ${this.#project.getName()}`); - - const deltaStageCache = await this.#findStageCache(stageName, [deltaInfo.originalSignature]); - if (deltaStageCache) { - log.verbose( - `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + - `with original signature ${deltaInfo.originalSignature} (now ${deltaInfo.newSignature}) ` + - `and ${deltaInfo.changedProjectResourcePaths.size} changed project resource paths and ` + - `${deltaInfo.changedDependencyResourcePaths.size} changed dependency resource paths.`); + if (this.#writtenResultResourcePaths.length) { + // Update task indices based on source changes and changes from by previous tasks + await taskCache.updateProjectIndices(this.#currentProjectReader, this.#writtenResultResourcePaths); + } - return { - previousStageCache: deltaStageCache, - newSignature: deltaInfo.newSignature, - changedProjectResourcePaths: deltaInfo.changedProjectResourcePaths, - changedDependencyResourcePaths: deltaInfo.changedDependencyResourcePaths - }; + // let deltaInfo; + // if (this.#invalidatedTasks.has(taskName)) { + // const invalidationInfo = + // this.#invalidatedTasks.get(taskName); + // log.verbose(`Task cache for task ${taskName} has been invalidated, updating indices ` + + // `with ${invalidationInfo.changedProjectResourcePaths.size} changed project resource paths and ` + + // `${invalidationInfo.changedDependencyResourcePaths.size} changed dependency resource paths...`); + + + // // deltaInfo = await taskCache.updateIndices( + // // invalidationInfo.changedProjectResourcePaths, + // // invalidationInfo.changedDependencyResourcePaths, + // // this.#currentProjectReader, this.#currentDependencyReader); + // } // else: Index will be created upon task completion + + // After index update, try to find cached stages for the new signatures + // let stageSignatures = taskCache.getAffiliatedSignaturePairs(); // TODO: Implement + + const stageSignatures = combineTwoArraysFast( + taskCache.getProjectIndexSignatures(), + taskCache.getDependencyIndexSignatures() + ).map((signaturePair) => { + return createStageSignature(...signaturePair); + }); + + const stageCache = await this.#findStageCache(stageName, stageSignatures); + if (stageCache) { + const stageChanged = this.#project.setStage(stageName, stageCache.stage); + + // Store dependency signature for later use in result stage signature calculation + this.#currentDependencySignatures.set(taskName, stageCache.signature.split("-")[1]); + + // Task can be skipped, use cached stage as project reader + // if (this.#invalidatedTasks.has(taskName)) { + // this.#invalidatedTasks.delete(taskName); + // } + + if (!stageChanged) { + // Invalidate following tasks + // this.#invalidateFollowingTasks(taskName, Array.from(stageCache.writtenResourcePaths)); + for (const resourcePath of stageCache.writtenResourcePaths) { + if (!this.#writtenResultResourcePaths.includes(resourcePath)) { + this.#writtenResultResourcePaths.push(resourcePath); + } } } + return true; // No need to execute the task } else { - log.verbose(`No task cache found`); + log.verbose(`No cached stage found for task ${taskName} in project ${this.#project.getName()}`); + // TODO: Re-implement + // const deltaInfo = taskCache.findDelta(); + + // const deltaStageCache = await this.#findStageCache(stageName, [deltaInfo.originalSignature]); + // if (deltaStageCache) { + // log.verbose( + // `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + + // `with original signature ${deltaInfo.originalSignature} (now ${deltaInfo.newSignature}) ` + + // `and ${deltaInfo.changedProjectResourcePaths.size} changed project resource paths and ` + + // `${deltaInfo.changedDependencyResourcePaths.size} changed dependency resource paths.`); + + // return { + // previousStageCache: deltaStageCache, + // newSignature: deltaInfo.newSignature, + // changedProjectResourcePaths: deltaInfo.changedProjectResourcePaths, + // changedDependencyResourcePaths: deltaInfo.changedDependencyResourcePaths + // }; + // } } - return false; // Task needs to be executed } @@ -396,17 +364,20 @@ export default class ProjectBuildCache { return stageCache; } } - + // TODO: If list of signatures is longer than N, + // retrieve all available signatures from cache manager first. + // Later maybe add a bloom filter for even larger sets const stageCache = await firstTruthy(stageSignatures.map(async (stageSignature) => { const stageMetadata = await this.#cacheManager.readStageCache( this.#project.getId(), this.#buildSignature, stageName, stageSignature); if (stageMetadata) { log.verbose(`Found cached stage with signature ${stageSignature}`); - const reader = await this.#createReaderForStageCache( + const reader = this.#createReaderForStageCache( stageName, stageSignature, stageMetadata.resourceMetadata); return { + signature: stageSignature, stage: reader, - writtenResourcePaths: new Set(Object.keys(stageMetadata.resourceMetadata)), + writtenResourcePaths: Object.keys(stageMetadata.resourceMetadata), }; } })); @@ -426,7 +397,7 @@ export default class ProjectBuildCache { * @param {string} taskName - Name of the executed task * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} projectResourceRequests * Resource requests for project resources - * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} dependencyResourceRequests + * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests|undefined} dependencyResourceRequests * Resource requests for dependency resources * @param {object} cacheInfo * @returns {Promise} @@ -436,7 +407,7 @@ export default class ProjectBuildCache { // Initialize task cache this.#taskCache.set(taskName, new BuildTaskCache(taskName, this.#project.getName())); } - log.verbose(`Updating build cache with results of task ${taskName} in project ${this.#project.getName()}`); + log.verbose(`Recording results of task ${taskName} in project ${this.#project.getName()}...`); const taskCache = this.#taskCache.get(taskName); // Identify resources written by task @@ -447,6 +418,7 @@ export default class ProjectBuildCache { let stageSignature; if (cacheInfo) { + // TODO: Update stageSignature = cacheInfo.newSignature; // Add resources from previous stage cache to current stage let reader; @@ -466,15 +438,18 @@ export default class ProjectBuildCache { } } else { // Calculate signature for executed task - stageSignature = await taskCache.calculateSignature( + const currentSignaturePair = await taskCache.recordRequests( projectResourceRequests, dependencyResourceRequests, this.#currentProjectReader, - this.#dependencyReader + this.#currentDependencyReader ); + // If provided, set dependency signature for later use in result stage signature calculation + this.#currentDependencySignatures.set(taskName, currentSignaturePair[1]); + stageSignature = createStageSignature(...currentSignaturePair); } - log.verbose(`Storing stage for task ${taskName} in project ${this.#project.getName()} ` + + log.verbose(`Caching stage for task ${taskName} in project ${this.#project.getName()} ` + `with signature ${stageSignature}`); // Store resulting stage in stage cache // TODO: Check whether signature already exists and avoid invalidating following tasks @@ -482,73 +457,21 @@ export default class ProjectBuildCache { this.#getStageNameForTask(taskName), stageSignature, this.#project.getStage(), writtenResourcePaths); - // Task has been successfully executed, remove from invalidated tasks - if (this.#invalidatedTasks.has(taskName)) { - this.#invalidatedTasks.delete(taskName); - } + // // Task has been successfully executed, remove from invalidated tasks + // if (this.#invalidatedTasks.has(taskName)) { + // this.#invalidatedTasks.delete(taskName); + // } // Update task cache with new metadata - if (writtenResourcePaths.length) { - log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); - this.#invalidateFollowingTasks(taskName, writtenResourcePaths); - } - // Reset current project reader - this.#currentProjectReader = null; - } - - /** - * Invalidates tasks that follow the given task if they depend on written resources - * - * Checks all tasks that come after the given task in execution order and - * invalidates those that match the written resource paths. - * - * @private - * @param {string} taskName - Name of the task that wrote resources - * @param {string[]} writtenResourcePaths - Paths of resources written by the task - */ - async #invalidateFollowingTasks(taskName, writtenResourcePaths) { - // Check whether following tasks need to be invalidated - const allTasks = Array.from(this.#taskCache.keys()); - const taskIdx = allTasks.indexOf(taskName); - for (let i = taskIdx + 1; i < allTasks.length; i++) { - const nextTaskName = allTasks[i]; - if (!await this.#taskCache.get(nextTaskName).matchesChangedResources(writtenResourcePaths, [])) { - continue; - } - if (this.#invalidatedTasks.has(nextTaskName)) { - const {changedProjectResourcePaths} = - this.#invalidatedTasks.get(nextTaskName); - for (const resourcePath of writtenResourcePaths) { - changedProjectResourcePaths.add(resourcePath); - } - } else { - this.#invalidatedTasks.set(nextTaskName, { - changedProjectResourcePaths: new Set(writtenResourcePaths), - changedDependencyResourcePaths: new Set() - }); - } - } + log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); for (const resourcePath of writtenResourcePaths) { - this.#changedResultResourcePaths.add(resourcePath); - } - } - - async #updateResultIndex(resourcePaths) { - const deltaReader = this.#project.getReader({excludeSourceReader: true}); - - const resources = await Promise.all(Array.from(resourcePaths).map(async (resourcePath) => { - const resource = await deltaReader.byPath(resourcePath); - if (!resource) { - throw new Error( - `Failed to update result index for project ${this.#project.getName()}: ` + - `resource at path ${resourcePath} not found in result reader`); + if (!this.#writtenResultResourcePaths.includes(resourcePath)) { + this.#writtenResultResourcePaths.push(resourcePath); } - return resource; - })); - - const res = await this.#resultIndex.upsertResources(resources); - return [...res.added, ...res.updated]; + } + // Reset current project reader + this.#currentProjectReader = null; } /** @@ -561,174 +484,38 @@ export default class ProjectBuildCache { return this.#taskCache.get(taskName); } - async projectSourcesChanged(changedPaths) { - for (const resourcePath of changedPaths) { - this.#changedProjectSourcePaths.add(resourcePath); - } - } - - /** - * Handles resource changes - * - * Iterates through all cached tasks and checks if any match the changed resources. - * Matching tasks are marked as invalidated and will need to be re-executed. - * Changed resource paths are accumulated if a task is already invalidated. - * - * @param {string[]} changedPaths - Changed project resource paths - * @returns {boolean} True if any task was invalidated, false otherwise - */ - // async projectResourcesChanged(changedPaths) { - // let taskInvalidated = false; - // for (const taskCache of this.#taskCache.values()) { - // if (await taskCache.isAffectedByProjectChanges(changedPaths)) { - // taskInvalidated = true; - // break; - // } - // } - // if (taskInvalidated) { - // for (const resourcePath of changedPaths) { - // this.#changedProjectResourcePaths.add(resourcePath); - // } - // } - // return taskInvalidated; - // } - /** - * Handles resource changes and invalidates affected tasks + * Records changed source files of the project and marks cache as stale * - * Iterates through all cached tasks and checks if any match the changed resources. - * Matching tasks are marked as invalidated and will need to be re-executed. - * Changed resource paths are accumulated if a task is already invalidated. - * - * @param {string[]} changedPaths - Changed dependency resource paths - * @returns {boolean} True if any task was invalidated, false otherwise + * @param {string[]} changedPaths - Changed project source file paths */ - async dependencyResourcesChanged(changedPaths) { - // let taskInvalidated = false; - // for (const taskCache of this.#taskCache.values()) { - // if (await taskCache.isAffectedByDependencyChanges(changedPaths)) { - // taskInvalidated = true; - // break; - // } - // } - // if (taskInvalidated) { + projectSourcesChanged(changedPaths) { for (const resourcePath of changedPaths) { - this.#changedDependencyResourcePaths.add(resourcePath); + if (!this.#changedProjectSourcePaths.includes(resourcePath)) { + this.#changedProjectSourcePaths.push(resourcePath); + } } - // } - // return taskInvalidated; - } - - async #flushPendingInputChanges() { - if (this.#changedProjectSourcePaths.size === 0 && - this.#changedDependencyResourcePaths.size === 0) { - return []; + if (this.#cacheState !== CACHE_STATES.EMPTY) { + // If there is a cache, mark it as stale + this.#cacheState = CACHE_STATES.STALE; } - await this._invalidateTasks( - Array.from(this.#changedProjectSourcePaths), - Array.from(this.#changedDependencyResourcePaths)); - - // Reset pending changes - this.#changedProjectSourcePaths = new Set(); - this.#changedDependencyResourcePaths = new Set(); } /** - * Handles resource changes and invalidates affected tasks - * - * Iterates through all cached tasks and checks if any match the changed resources. - * Matching tasks are marked as invalidated and will need to be re-executed. - * Changed resource paths are accumulated if a task is already invalidated. + * Records changed dependency resources and marks cache as stale * - * @param {string[]} projectResourcePaths - Changed project resource paths - * @param {string[]} dependencyResourcePaths - Changed dependency resource paths - * @returns {boolean} True if any task was invalidated, false otherwise + * @param {string[]} changedPaths - Changed dependency resource paths */ - async _invalidateTasks(projectResourcePaths, dependencyResourcePaths) { - let taskInvalidated = false; - for (const [taskName, taskCache] of this.#taskCache) { - if (!await taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { - continue; - } - taskInvalidated = true; - if (this.#invalidatedTasks.has(taskName)) { - const {changedProjectResourcePaths, changedDependencyResourcePaths} = - this.#invalidatedTasks.get(taskName); - for (const resourcePath of projectResourcePaths) { - changedProjectResourcePaths.add(resourcePath); - } - for (const resourcePath of dependencyResourcePaths) { - changedDependencyResourcePaths.add(resourcePath); - } - } else { - this.#invalidatedTasks.set(taskName, { - changedProjectResourcePaths: new Set(projectResourcePaths), - changedDependencyResourcePaths: new Set(dependencyResourcePaths) - }); + dependencyResourcesChanged(changedPaths) { + for (const resourcePath of changedPaths) { + if (!this.#changedDependencyResourcePaths.includes(resourcePath)) { + this.#changedDependencyResourcePaths.push(resourcePath); } } - return taskInvalidated; - } - - // async areTasksAffectedByResource(projectResourcePaths, dependencyResourcePaths) { - // for (const taskCache of this.#taskCache.values()) { - // if (await taskCache.matchesChangedResources(projectResourcePaths, dependencyResourcePaths)) { - // return true; - // } - // } - // } - - /** - * Gets the set of changed project resource paths for a task - * - * @param {string} taskName - Name of the task - * @returns {Set} Set of changed project resource paths - */ - getChangedProjectResourcePaths(taskName) { - return this.#invalidatedTasks.get(taskName)?.changedProjectResourcePaths ?? new Set(); - } - - /** - * Gets the set of changed dependency resource paths for a task - * - * @param {string} taskName - Name of the task - * @returns {Set} Set of changed dependency resource paths - */ - getChangedDependencyResourcePaths(taskName) { - return this.#invalidatedTasks.get(taskName)?.changedDependencyResourcePaths ?? new Set(); - } - - // ===== CACHE QUERIES ===== - - /** - * Checks if any task cache exists - * - * @returns {boolean} True if at least one task has been cached - */ - hasAnyTaskCache() { - return this.#taskCache.size > 0; - } - - /** - * Checks whether the project's build cache has an entry for the given task - * - * This means that the cache has been filled with the input and output of the given task. - * - * @param {string} taskName - Name of the task - * @returns {boolean} True if cache exists for this task - */ - hasTaskCache(taskName) { - return this.#taskCache.has(taskName); - } - - /** - * Checks whether the cache for a specific task is currently valid - * - * @param {string} taskName - Name of the task - * @returns {boolean} True if cache exists and is valid for this task - */ - isTaskCacheValid(taskName) { - return this.#taskCache.has(taskName) && !this.#invalidatedTasks.has(taskName); + if (this.#cacheState !== CACHE_STATES.EMPTY) { + // If there is a cache, mark it as stale + this.#cacheState = CACHE_STATES.STALE; + } } /** @@ -747,19 +534,6 @@ export default class ProjectBuildCache { // TODO: Rename function? We simply use it to have a point in time right before the project is built } - /** - * Sets the dependency reader for accessing dependency resources - * - * The dependency reader is used by tasks to access resources from project - * dependencies. Must be set before tasks that require dependencies are executed. - * - * @param {@ui5/fs/AbstractReader} dependencyReader - Reader for dependency resources - * @returns {void} - */ - setDependencyReader(dependencyReader) { - this.#dependencyReader = dependencyReader; - } - /** * Signals that all tasks have completed and switches to the result stage * @@ -771,26 +545,17 @@ export default class ProjectBuildCache { */ async allTasksCompleted() { this.#project.useResultStage(); - this.#usingResultStage = true; - const changedPaths = await this.#updateResultIndex(this.#changedResultResourcePaths); + this.#cacheState = CACHE_STATES.FRESH; + const changedPaths = this.#writtenResultResourcePaths; + + this.#currentResultSignature = this.#getResultStageSignature(); // Reset updated resource paths - this.#changedResultResourcePaths = new Set(); + this.#writtenResultResourcePaths = []; + this.#currentDependencySignatures = new Map(); return changedPaths; } - /** - * Gets the names of all invalidated tasks - * - * Invalidated tasks are those that need to be re-executed because their - * input resources have changed. - * - * @returns {string[]} Array of task names that have been invalidated - */ - getInvalidatedTaskNames() { - return Array.from(this.#invalidatedTasks.keys()); - } - /** * Generates the stage name for a given task * @@ -802,13 +567,128 @@ export default class ProjectBuildCache { return `task/${taskName}`; } + /** + * Initializes the resource index from cache or creates a new one + * + * This method attempts to load a cached resource index. If found, it validates + * the index against current source files and invalidates affected tasks if + * resources have changed. If no cache exists, creates a fresh index. + * + * @private + * @throws {Error} If cached index signature doesn't match computed signature + */ + async #initSourceIndex() { + const sourceReader = this.#project.getSourceReader(); + const [resources, indexCache] = await Promise.all([ + await sourceReader.byGlob("/**/*"), + await this.#cacheManager.readIndexCache(this.#project.getId(), this.#buildSignature, "source"), + ]); + if (indexCache) { + log.verbose(`Using cached resource index for project ${this.#project.getName()}`); + // Create and diff resource index + const {resourceIndex, changedPaths} = + await ResourceIndex.fromCacheWithDelta(indexCache, resources, Date.now()); + + // Import task caches + const buildTaskCaches = await Promise.all(indexCache.taskList.map(async (taskName) => { + const projectRequests = await this.#cacheManager.readTaskMetadata( + this.#project.getId(), this.#buildSignature, `${taskName}-pr`); + if (!projectRequests) { + throw new Error(`Failed to load project request cache for task ` + + `${taskName} in project ${this.#project.getName()}`); + } + const dependencyRequests = await this.#cacheManager.readTaskMetadata( + this.#project.getId(), this.#buildSignature, `${taskName}-dr`); + if (!dependencyRequests) { + throw new Error(`Failed to load dependency request cache for task ` + + `${taskName} in project ${this.#project.getName()}`); + } + return new BuildTaskCache(taskName, this.#project.getName(), { + projectRequests, + dependencyRequests, + }); + })); + // Ensure taskCache is filled in the order of task execution + for (const buildTaskCache of buildTaskCaches) { + this.#taskCache.set(buildTaskCache.getTaskName(), buildTaskCache); + } + + if (changedPaths.length) { + this.#cacheState = CACHE_STATES.DIRTY; + } else { + this.#cacheState = CACHE_STATES.INITIALIZED; + } + // // Invalidate tasks based on changed resources + // // Note: If the changed paths don't affect any task, the index cache still can't be used due to the + // // root hash mismatch. + // // Since no tasks have been invalidated, a rebuild is still necessary in this case, so that + // // each task can find and use its individual stage cache. + // // Hence requiresInitialBuild will be set to true in this case (and others. + // // const tasksInvalidated = await this.#invalidateTasks(changedPaths, []); + // // if (!tasksInvalidated) { + + // // } + // } else if (indexCache.indexTree.root.hash !== resourceIndex.getSignature()) { + // // Validate index signature matches with cached signature + // throw new Error( + // `Resource index signature mismatch for project ${this.#project.getName()}: ` + + // `expected ${indexCache.indexTree.root.hash}, got ${resourceIndex.getSignature()}`); + // } + + // else { + // log.verbose( + // `Resource index signature for project ${this.#project.getName()} matches cached signature: ` + + // `${resourceIndex.getSignature()}`); + // // this.#cachedSourceSignature = resourceIndex.getSignature(); + // } + this.#sourceIndex = resourceIndex; + this.#cachedSourceSignature = resourceIndex.getSignature(); + this.#changedProjectSourcePaths = changedPaths; + this.#writtenResultResourcePaths = changedPaths; + } else { + // No index cache found, create new index + this.#sourceIndex = await ResourceIndex.create(resources, Date.now()); + this.#cacheState = CACHE_STATES.EMPTY; + } + } + + async #updateSourceIndex(changedResourcePaths) { + const sourceReader = this.#project.getSourceReader(); + + const resources = await Promise.all(changedResourcePaths.map((resourcePath) => { + return sourceReader.byPath(resourcePath); + })); + const removedResources = []; + const foundResources = resources.filter((resource) => { + if (!resource) { + removedResources.push(resource); + return false; + } + return true; + }); + const {removed} = await this.#sourceIndex.removeResources(removedResources); + const {added, updated} = await this.#sourceIndex.upsertResources(foundResources, Date.now()); + + if (removed.length || added.length || updated.length) { + log.verbose(`Source resource index for project ${this.#project.getName()} updated: ` + + `${removed.length} removed, ${added.length} added, ${updated.length} updated resources.`); + const changedPaths = [...removed, ...added, ...updated]; + for (const resourcePath of changedPaths) { + if (!this.#writtenResultResourcePaths.includes(resourcePath)) { + this.#writtenResultResourcePaths.push(resourcePath); + } + } + return true; + } + return false; + } + // ===== CACHE SERIALIZATION ===== /** * Stores all cache data to persistent storage * * This method: - * 1. Writes the build manifest (if not already written) * 2. Stores the result stage with all resources * 3. Writes the resource index and task metadata * 4. Stores all stage caches from the queue @@ -819,46 +699,14 @@ export default class ProjectBuildCache { * @returns {Promise} */ async writeCache(buildManifest) { - // if (!this.#buildManifest) { - // log.verbose(`Storing build manifest for project ${this.#project.getName()} ` + - // `with build signature ${this.#buildSignature}`); - // // Write build manifest if it wasn't loaded from cache before - // this.#buildManifest = buildManifest; - // await this.#cacheManager.writeBuildManifest(this.#project.getId(), this.#buildSignature, buildManifest); - // } - - // Store result stage - await this.#writeResultIndex(); - - // Store task caches - for (const [taskName, taskCache] of this.#taskCache) { - if (taskCache.hasNewOrModifiedCacheEntries()) { - log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - await this.#cacheManager.writeTaskMetadata(this.#project.getId(), this.#buildSignature, taskName, - taskCache.toCacheObject()); - } - } - - await this.#writeStageCaches(); + await Promise.all([ + this.#writeResultStageCache(), - await this.#writeSourceIndex(); - } + this.#writeTaskStageCaches(), + this.#writeTaskMetadataCaches(), - async #writeSourceIndex() { - if (this.#cachedSourceSignature === this.#sourceIndex.getSignature()) { - // No changes to already cached result index - return; - } - - // Finally store index cache - log.verbose(`Storing resource index cache for project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); - const sourceIndexObject = this.#sourceIndex.toCacheObject(); - await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "source", { - ...sourceIndexObject, - taskList: Array.from(this.#taskCache.keys()), - }); + this.#writeSourceIndex(), + ]); } /** @@ -868,22 +716,21 @@ export default class ProjectBuildCache { * stores their content via the cache manager, and writes stage metadata * including resource information. * - * @private * @returns {Promise} */ - async #writeResultIndex() { - if (this.#cachedResultSignature === this.#resultIndex.getSignature()) { - // No changes to already cached result index + async #writeResultStageCache() { + const stageSignature = this.#currentResultSignature; + if (stageSignature === this.#cachedResultSignature) { + // No changes to already cached result stage return; } - const stageSignature = this.#currentResultSignature; const stageId = "result"; - const deltaReader = this.#project.getReader({excludeSourceReader: true}); const resources = await deltaReader.byGlob("/**/*"); const resourceMetadata = Object.create(null); - log.verbose(`Project ${this.#project.getName()} resource index signature: ${stageSignature}`); - log.verbose(`Caching result stage with ${resources.length} resources`); + log.verbose(`Project ${this.#project.getName()} result stage signature is: ${stageSignature}`); + log.verbose(`Cache state: ${this.#cacheState}`); + log.verbose(`Storing result stage cache with ${resources.length} resources`); await Promise.all(resources.map(async (res) => { // Store resource content in cacache via CacheManager @@ -902,15 +749,12 @@ export default class ProjectBuildCache { }; await this.#cacheManager.writeStageCache( this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); - - // After all resources have been stored, write updated result index hash tree - const resultIndexObject = this.#resultIndex.toCacheObject(); - await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "result", { - ...resultIndexObject - }); } - async #writeStageCaches() { + async #writeTaskStageCaches() { + if (!this.#stageCache.hasPendingCacheQueue()) { + return; + } // Store stage caches log.verbose(`Storing stage caches for project ${this.#project.getName()} ` + `with build signature ${this.#buildSignature}`); @@ -941,10 +785,39 @@ export default class ProjectBuildCache { })); } - #createBuildTaskCacheMetadataReader(taskName) { - return () => { - return this.#cacheManager.readTaskMetadata(this.#project.getId(), this.#buildSignature, taskName); - }; + async #writeTaskMetadataCaches() { + // Store task caches + for (const [taskName, taskCache] of this.#taskCache) { + if (taskCache.hasNewOrModifiedCacheEntries()) { + const {projectRequests, dependencyRequests} = taskCache.toCacheObjects(); + log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + const writes = []; + if (projectRequests) { + writes.push(this.#cacheManager.writeTaskMetadata( + this.#project.getId(), this.#buildSignature, `${taskName}-pr`, projectRequests)); + } + if (dependencyRequests) { + writes.push(this.#cacheManager.writeTaskMetadata( + this.#project.getId(), this.#buildSignature, `${taskName}-dr`, dependencyRequests)); + } + await Promise.all(writes); + } + } + } + + async #writeSourceIndex() { + if (this.#cachedSourceSignature === this.#sourceIndex.getSignature()) { + // No changes to already cached result index + return; + } + log.verbose(`Storing resource index cache for project ${this.#project.getName()} ` + + `with build signature ${this.#buildSignature}`); + const sourceIndexObject = this.#sourceIndex.toCacheObject(); + await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "source", { + ...sourceIndexObject, + taskList: Array.from(this.#taskCache.keys()), + }); } /** @@ -959,7 +832,7 @@ export default class ProjectBuildCache { * @param {Object} resourceMetadata - Metadata for all cached resources * @returns {Promise<@ui5/fs/AbstractReader>} Proxy reader for cached resources */ - async #createReaderForStageCache(stageId, stageSignature, resourceMetadata) { + #createReaderForStageCache(stageId, stageSignature, resourceMetadata) { const allResourcePaths = Object.keys(resourceMetadata); return createProxy({ name: `Cache reader for task ${stageId} in project ${this.#project.getName()}`, @@ -1005,50 +878,46 @@ export default class ProjectBuildCache { } }); } - /** - * Loads and validates the build manifest from persistent storage - * - * Attempts to load the build manifest and performs validation: - * - Checks manifest version compatibility (must be "1.0") - * - Validates build signature matches the expected signature - * - * If validation fails, the cache is considered invalid and will be ignored. - * - * @param taskName - * @private - * @returns {Promise} Build manifest object or undefined if not found/invalid - * @throws {Error} If build signature mismatch or cache restoration fails - */ - // async #loadBuildManifest() { - // const manifest = await this.#cacheManager.readBuildManifest(this.#project.getId(), this.#buildSignature); - // if (!manifest) { - // log.verbose(`No build manifest found for project ${this.#project.getName()} ` + - // `with build signature ${this.#buildSignature}`); - // return; - // } - - // try { - // // Check build manifest version - // const {buildManifest} = manifest; - // if (buildManifest.manifestVersion !== "1.0") { - // log.verbose(`Incompatible build manifest version ${manifest.version} found for project ` + - // `${this.#project.getName()} with build signature ${this.#buildSignature}. Ignoring cache.`); - // return; - // } - // // TODO: Validate manifest against a schema - - // // Validate build signature match - // if (this.#buildSignature !== manifest.buildManifest.signature) { - // throw new Error( - // `Build manifest signature ${manifest.buildManifest.signature} does not match expected ` + - // `build signature ${this.#buildSignature} for project ${this.#project.getName()}`); - // } - // return buildManifest; - // } catch (err) { - // throw new Error( - // `Failed to restore cache from disk for project ${this.#project.getName()}: ${err.message}`, { - // cause: err - // }); - // } - // } +} + +function cartesianProduct(arrays) { + if (arrays.length === 0) return [[]]; + if (arrays.some((arr) => arr.length === 0)) return []; + + let result = [[]]; + + for (const array of arrays) { + const temp = []; + for (const resultItem of result) { + for (const item of array) { + temp.push([...resultItem, item]); + } + } + result = temp; + } + + return result; +} + +function combineTwoArraysFast(array1, array2) { + const len1 = array1.length; + const len2 = array2.length; + const result = new Array(len1 * len2); + + let idx = 0; + for (let i = 0; i < len1; i++) { + for (let j = 0; j < len2; j++) { + result[idx++] = [array1[i], array2[j]]; + } + } + + return result; +} + +function createStageSignature(projectSignature, dependencySignature) { + return `${projectSignature}-${dependencySignature}`; +} + +function createDependencySignature(stageDependencySignatures) { + return crypto.createHash("sha256").update(stageDependencySignatures.join("")).digest("hex"); } diff --git a/packages/project/lib/build/cache/ResourceRequestGraph.js b/packages/project/lib/build/cache/ResourceRequestGraph.js index 65366ac3885..099939a3cea 100644 --- a/packages/project/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/lib/build/cache/ResourceRequestGraph.js @@ -1,11 +1,11 @@ -const ALLOWED_REQUEST_TYPES = new Set(["path", "patterns", "dep-path", "dep-patterns"]); +const ALLOWED_REQUEST_TYPES = new Set(["path", "patterns"]); /** * Represents a single request with type and value */ export class Request { /** - * @param {string} type - Either 'path', 'pattern', "dep-path" or "dep-pattern" + * @param {string} type - Either 'path' or 'pattern' * @param {string|string[]} value - The request value (string for path types, array for pattern types) */ constructor(type, value) { @@ -14,7 +14,7 @@ export class Request { } // Validate value type based on request type - if ((type === "path" || type === "dep-path") && typeof value !== "string") { + if (type === "path" && typeof value !== "string") { throw new Error(`Request type '${type}' requires value to be a string`); } @@ -368,6 +368,10 @@ export default class ResourceRequestGraph { }; } + getSize() { + return this.nodes.size; + } + /** * Iterate through nodes in breadth-first order (by depth level). * Parents are always yielded before their children, allowing efficient traversal diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js new file mode 100644 index 00000000000..6a2fcb05de1 --- /dev/null +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -0,0 +1,533 @@ +import micromatch from "micromatch"; +import ResourceRequestGraph, {Request} from "./ResourceRequestGraph.js"; +import ResourceIndex from "./index/ResourceIndex.js"; +import TreeRegistry from "./index/TreeRegistry.js"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:cache:ResourceRequestManager"); + +class ResourceRequestManager { + #taskName; + #projectName; + #requestGraph; + + #treeRegistries = []; + #treeDiffs = new Map(); + + #hasNewOrModifiedCacheEntries; + #useDifferentialUpdate = true; + + constructor(taskName, projectName, requestGraph) { + this.#taskName = taskName; + this.#projectName = projectName; + if (requestGraph) { + this.#requestGraph = requestGraph; + this.#hasNewOrModifiedCacheEntries = false; // Using cache + } else { + this.#requestGraph = new ResourceRequestGraph(); + this.#hasNewOrModifiedCacheEntries = true; + } + } + + static fromCache(taskName, projectName, {requestSetGraph, rootIndices, deltaIndices}) { + const requestGraph = ResourceRequestGraph.fromCacheObject(requestSetGraph); + const resourceRequestManager = new ResourceRequestManager(taskName, projectName, requestGraph); + const registries = new Map(); + // Restore root resource indices + for (const {nodeId, resourceIndex: serializedIndex} of rootIndices) { + const metadata = requestGraph.getMetadata(nodeId); + const registry = resourceRequestManager.#newTreeRegistry(); + registries.set(nodeId, registry); + metadata.resourceIndex = ResourceIndex.fromCache(serializedIndex, registry); + } + // Restore delta resource indices + if (deltaIndices) { + for (const {nodeId, addedResourceIndex} of deltaIndices) { + const node = requestGraph.getNode(nodeId); + const {resourceIndex: parentResourceIndex} = requestGraph.getMetadata(node.getParentId()); + const registry = registries.get(node.getParentId()); + if (!registry) { + throw new Error(`Missing tree registry for parent of node ID ${nodeId} of task ` + + `'${this.#taskName}' of project '${this.#projectName}'`); + } + const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex); + + requestGraph.setMetadata(nodeId, { + resourceIndex, + }); + } + } + return resourceRequestManager; + } + + /** + * Gets all project index signatures for this task + * + * Returns signatures from all recorded project-request sets. Each signature represents + * a unique combination of resources belonging to the current project that were accessed + * during task execution. This can be used to form a cache keys for restoring cached task results. + * + * @returns {Promise} Array of signature strings + * @throws {Error} If resource index is missing for any request set + */ + getIndexSignatures() { + const requestSetIds = this.#requestGraph.getAllNodeIds(); + if (requestSetIds.length === 0) { + return ["X"]; // No requests recorded, return static signature + } + const signatures = requestSetIds.map((requestSetId) => { + const {resourceIndex} = this.#requestGraph.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Resource index missing for request set ID ${requestSetId}`); + } + return resourceIndex.getSignature(); + }); + return signatures; + } + + /** + * Update all indices based on current resources (no delta update) + * + * @param {module:@ui5/fs.AbstractReader} reader - Reader for accessing project resources + */ + async refreshIndices(reader) { + if (this.#requestGraph.getSize() === 0) { + // No requests recorded -> No updates necessary + return false; + } + + const resourceCache = new Map(); + for (const {nodeId} of this.#requestGraph.traverseByDepth()) { + const {resourceIndex} = this.#requestGraph.getMetadata(nodeId); + if (!resourceIndex) { + throw new Error(`Missing resource index for request set ID ${nodeId}`); + } + const addedRequests = this.#requestGraph.getNode(nodeId).getAddedRequests(); + const resourcesToUpdate = await this.#getResourcesForRequests(addedRequests, reader, resourceCache); + + // Determine resources to remove + const indexedResourcePaths = resourceIndex.getResourcePaths(); + const currentResourcePaths = resourcesToUpdate.map((res) => res.getOriginalPath()); + const resourcesToRemove = indexedResourcePaths.filter((resPath) => { + return !currentResourcePaths.includes(resPath); + }); + if (resourcesToRemove.length) { + await resourceIndex.removeResources(resourcesToRemove); + } + if (resourcesToUpdate.length) { + await resourceIndex.upsertResources(resourcesToUpdate); + } + } + } + + /** + * Filter relevant resource changes and update the indices if necessary + * + * @param {module:@ui5/fs.AbstractReader} reader - Reader for accessing project resources + * @param {string[]} changedResourcePaths - Array of changed project resource path + * @returns {Promise} True if any changes were detected, false otherwise + */ + async updateIndices(reader, changedResourcePaths) { + const matchingRequestSetIds = []; + const updatesByRequestSetId = new Map(); + if (this.#requestGraph.getSize() === 0) { + // No requests recorded -> No updates necessary + return false; + } + + // Process all nodes, parents before children + for (const {nodeId, node, parentId} of this.#requestGraph.traverseByDepth()) { + const addedRequests = node.getAddedRequests(); // Resource requests added at this level + let relevantUpdates; + if (addedRequests.length) { + relevantUpdates = this.#matchResourcePaths(addedRequests, changedResourcePaths); + } else { + relevantUpdates = []; + } + if (parentId) { + // Include updates from parent nodes + const parentUpdates = updatesByRequestSetId.get(parentId); + if (parentUpdates && parentUpdates.length) { + relevantUpdates.push(...parentUpdates); + } + } + if (relevantUpdates.length) { + updatesByRequestSetId.set(nodeId, relevantUpdates); + matchingRequestSetIds.push(nodeId); + } + } + + const resourceCache = new Map(); + // Update matching resource indices + for (const requestSetId of matchingRequestSetIds) { + const {resourceIndex} = this.#requestGraph.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Missing resource index for request set ID ${requestSetId}`); + } + + const resourcePathsToUpdate = updatesByRequestSetId.get(requestSetId); + const resourcesToUpdate = []; + const removedResourcePaths = []; + for (const resourcePath of resourcePathsToUpdate) { + let resource; + if (resourceCache.has(resourcePath)) { + resource = resourceCache.get(resourcePath); + } else { + resource = await reader.byPath(resourcePath); + resourceCache.set(resourcePath, resource); + } + if (resource) { + resourcesToUpdate.push(resource); + } else { + // Resource has been removed + removedResourcePaths.push(resourcePath); + } + } + if (removedResourcePaths.length) { + await resourceIndex.removeResources(removedResourcePaths); + } + if (resourcesToUpdate.length) { + await resourceIndex.upsertResources(resourcesToUpdate); + } + } + if (this.#useDifferentialUpdate) { + return await this.#flushTreeChangesWithDiffTracking(); + } else { + return await this.#flushTreeChangesWithoutDiffTracking(); + } + } + + /** + * Matches changed resources against a set of requests + * + * Tests each request against the changed resource paths using exact path matching + * for 'path'/'dep-path' requests and glob pattern matching for 'patterns'/'dep-patterns' requests. + * + * @private + * @param {Request[]} resourceRequests - Array of resource requests to match against + * @param {string[]} resourcePaths - Changed project resource paths + * @returns {string[]} Array of matched resource paths + */ + #matchResourcePaths(resourceRequests, resourcePaths) { + const matchedResources = []; + for (const {type, value} of resourceRequests) { + if (type === "path") { + if (resourcePaths.includes(value)) { + matchedResources.push(value); + } + } else { + matchedResources.push(...micromatch(resourcePaths, value)); + } + } + return matchedResources; + } + + /** + * Flushes all tree registries to apply batched updates, ignoring how trees changed + * + * @returns {Promise} True if any changes were detected, false otherwise + */ + async #flushTreeChangesWithoutDiffTracking() { + const results = await this.#flushTreeChanges(); + + // Check for changes + for (const res of results) { + if (res.added.length || res.updated.length || res.unchanged.length || res.removed.length) { + return true; + } + } + return false; + } + + /** + * Flushes all tree registries to apply batched updates, keeping track of how trees changed + * + * @returns {Promise} True if any changes were detected, false otherwise + */ + async #flushTreeChangesWithDiffTracking() { + const requestSetIds = this.#requestGraph.getAllNodeIds(); + // Record current signatures and create mapping between trees and request sets + requestSetIds.map((requestSetId) => { + const {resourceIndex} = this.#requestGraph.getMetadata(requestSetId); + if (!resourceIndex) { + throw new Error(`Resource index missing for request set ID ${requestSetId}`); + } + // Store original signatures for all trees that are not yet tracked + if (!this.#treeDiffs.has(resourceIndex.getTree())) { + this.#treeDiffs.set(resourceIndex.getTree(), { + requestSetId, + signature: resourceIndex.getSignature(), + }); + } + }); + const results = await this.#flushTreeChanges(); + let hasChanges = false; + for (const res of results) { + if (res.added.length || res.updated.length || res.unchanged.length || res.removed.length) { + hasChanges = true; + } + for (const [tree, stats] of res.treeStats) { + this.#addStatsToTreeDiff(this.#treeDiffs.get(tree), stats); + } + } + return hasChanges; + + // let greatestNumberOfChanges = 0; + // let relevantTree; + // let relevantStats; + // let hasChanges = false; + // const results = await this.#flushTreeChanges(); + + // // Based on the returned stats, find the tree with the greatest difference + // // If none of the updated trees lead to a valid cache, this tree can be used to execute a differential + // // build (assuming there's a cache for its previous signature) + // for (const res of results) { + // if (res.added.length || res.updated.length || res.unchanged.length || res.removed.length) { + // hasChanges = true; + // } + // for (const [tree, stats] of res.treeStats) { + // if (stats.removed.length > 0) { + // // If the update process removed resources from that tree, this means that using it in a + // // differential build might lead to stale removed resources + // return; // TODO: continue; instead? + // } + // const numberOfChanges = stats.added.length + stats.updated.length; + // if (numberOfChanges > greatestNumberOfChanges) { + // greatestNumberOfChanges = numberOfChanges; + // relevantTree = tree; + // relevantStats = stats; + // } + // } + // } + // if (hasChanges) { + // this.#hasNewOrModifiedCacheEntries = true; + // } + + // if (!relevantTree) { + // return hasChanges; + // } + + // // Update signatures for affected request sets + // const {requestSetId, signature: originalSignature} = trees.get(relevantTree); + // const newSignature = relevantTree.getRootHash(); + // log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + + // `updated resource index for request set ID ${requestSetId} ` + + // `from signature ${originalSignature} ` + + // `to ${newSignature}`); + + // const changedPaths = new Set(); + // for (const path of relevantStats.added) { + // changedPaths.add(path); + // } + // for (const path of relevantStats.updated) { + // changedPaths.add(path); + // } + + // return { + // originalSignature, + // newSignature, + // changedPaths, + // }; + } + + /** + * Flushes all tree registries to apply batched updates + * + * Commits all pending tree modifications across all registries in parallel. + * Must be called after operations that schedule updates via registries. + * + * @returns {Promise} Object containing sets of added, updated, and removed resource paths + */ + async #flushTreeChanges() { + return await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); + } + + #addStatsToTreeDiff(treeDiff, stats) { + if (!treeDiff.stats) { + treeDiff.stats = { + added: new Set(), + updated: new Set(), + unchanged: new Set(), + removed: new Set(), + }; + } + for (const path of stats.added) { + treeDiff.stats.added.add(path); + } + for (const path of stats.updated) { + treeDiff.stats.updated.add(path); + } + for (const path of stats.unchanged) { + treeDiff.stats.unchanged.add(path); + } + for (const path of stats.removed) { + treeDiff.stats.removed.add(path); + } + } + + /** + * + * @param {ResourceRequests} requestRecording - Project resource requests (paths and patterns) + * @param {module:@ui5/fs.AbstractReader} reader - Reader for accessing project resources + * @returns {Promise} Signature hash string of the resource index + */ + async addRequests(requestRecording, reader) { + const projectRequests = []; + for (const pathRead of requestRecording.paths) { + projectRequests.push(new Request("path", pathRead)); + } + for (const patterns of requestRecording.patterns) { + projectRequests.push(new Request("patterns", patterns)); + } + return await this.#addRequestSet(projectRequests, reader); + } + + async #addRequestSet(requests, reader) { + // Try to find an existing request set that we can reuse + let setId = this.#requestGraph.findExactMatch(requests); + let resourceIndex; + if (setId) { + // Reuse existing resource index. + // Note: This index has already been updated before the task executed, so no update is necessary here + resourceIndex = this.#requestGraph.getMetadata(setId).resourceIndex; + } else { + // New request set, check whether we can create a delta + const metadata = {}; // Will populate with resourceIndex below + setId = this.#requestGraph.addRequestSet(requests, metadata); + + const requestSet = this.#requestGraph.getNode(setId); + const parentId = requestSet.getParentId(); + if (parentId) { + const {resourceIndex: parentResourceIndex} = this.#requestGraph.getMetadata(parentId); + // Add resources from delta to index + const addedRequests = requestSet.getAddedRequests(); + const resourcesToAdd = + await this.#getResourcesForRequests(addedRequests, reader); + if (!resourcesToAdd.length) { + throw new Error(`Unexpected empty added resources for request set ID ${setId} ` + + `of task '${this.#taskName}' of project '${this.#projectName}'`); + } + log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + + `created derived resource index for request set ID ${setId} ` + + `based on parent ID ${parentId} with ${resourcesToAdd.length} additional resources`); + resourceIndex = await parentResourceIndex.deriveTree(resourcesToAdd); + } else { + const resourcesRead = + await this.#getResourcesForRequests(requests, reader); + resourceIndex = await ResourceIndex.create(resourcesRead, Date.now(), this.#newTreeRegistry()); + } + metadata.resourceIndex = resourceIndex; + } + return { + setId, + signature: resourceIndex.getSignature(), + }; + } + + addAffiliatedRequestSet(ourRequestSetId, foreignRequestSetId) { + // TODO + } + + /** + * Creates and registers a new tree registry + * + * Tree registries enable batched updates across multiple derived trees, + * improving performance when multiple indices share common subtrees. + * + * @returns {TreeRegistry} New tree registry instance + */ + #newTreeRegistry() { + const registry = new TreeRegistry(); + this.#treeRegistries.push(registry); + return registry; + } + + /** + * Retrieves resources for a set of resource requests + * + * Processes different request types: + * - 'path': Retrieves single resource by path from the given reader + * - 'patterns': Retrieves resources matching glob patterns from the given reader + * + * @private + * @param {Request[]|Array<{type: string, value: string|string[]}>} resourceRequests - Resource requests to process + * @param {module:@ui5/fs.AbstractReader} reader - Resource reader + * @param {Map} [resourceCache] + * @returns {Promise>} Array of matched resources + */ + async #getResourcesForRequests(resourceRequests, reader, resourceCache) { + const resourcesMap = new Map(); + await Promise.all(resourceRequests.map(async ({type, value}) => { + if (type === "path") { + if (resourcesMap.has(value)) { + // Resource already found + return; + } + if (resourceCache?.has(value)) { + const cachedResource = resourceCache.get(value); + resourcesMap.set(cachedResource.getOriginalPath(), cachedResource); + } + const resource = await reader.byPath(value); + if (resource) { + resourcesMap.set(resource.getOriginalPath(), resource); + } + } else if (type === "patterns") { + const matchedResources = await reader.byGlob(value); + for (const resource of matchedResources) { + resourcesMap.set(resource.getOriginalPath(), resource); + } + } + })); + return Array.from(resourcesMap.values()); + } + + hasNewOrModifiedCacheEntries() { + return this.#hasNewOrModifiedCacheEntries; + } + + /** + * Serializes the task cache to a plain object for persistence + * + * Exports the resource request graph in a format suitable for JSON serialization. + * The serialized data can be passed to the constructor to restore the cache state. + * + * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph + */ + toCacheObject() { + if (!this.#hasNewOrModifiedCacheEntries) { + return; + } + const rootIndices = []; + const deltaIndices = []; + for (const {nodeId, parentId} of this.#requestGraph.traverseByDepth()) { + const {resourceIndex} = this.#requestGraph.getMetadata(nodeId); + if (!resourceIndex) { + throw new Error(`Missing resource index for node ID ${nodeId}`); + } + if (!parentId) { + rootIndices.push({ + nodeId, + resourceIndex: resourceIndex.toCacheObject(), + }); + } else { + const {resourceIndex: rootResourceIndex} = this.#requestGraph.getMetadata(parentId); + if (!rootResourceIndex) { + throw new Error(`Missing root resource index for parent ID ${parentId}`); + } + // Store the metadata for all added resources. Note: Those resources might not be available + // in the current tree. In that case we store an empty array. + const addedResourceIndex = resourceIndex.getAddedResourceIndex(rootResourceIndex); + deltaIndices.push({ + nodeId, + addedResourceIndex, + }); + } + } + return { + requestSetGraph: this.#requestGraph.toCacheObject(), + rootIndices, + deltaIndices, + }; + } +} + +export default ResourceRequestManager; diff --git a/packages/project/lib/build/cache/StageCache.js b/packages/project/lib/build/cache/StageCache.js index 519531720c6..cd9031c197a 100644 --- a/packages/project/lib/build/cache/StageCache.js +++ b/packages/project/lib/build/cache/StageCache.js @@ -1,7 +1,7 @@ /** * @typedef {object} StageCacheEntry * @property {object} stage - The cached stage instance (typically a reader or writer) - * @property {Set} writtenResourcePaths - Set of resource paths written during stage execution + * @property {string[]} writtenResourcePaths - Set of resource paths written during stage execution */ /** @@ -36,7 +36,7 @@ export default class StageCache { * @param {string} stageId - Identifier for the stage (e.g., "task/generateBundle") * @param {string} signature - Content hash signature of the stage's input resources * @param {object} stageInstance - The stage instance to cache (typically a reader or writer) - * @param {Set} writtenResourcePaths - Set of resource paths written during this stage + * @param {string[]} writtenResourcePaths - Set of resource paths written during this stage * @returns {void} */ addSignature(stageId, signature, stageInstance, writtenResourcePaths) { @@ -45,6 +45,7 @@ export default class StageCache { } const signatureToStageInstance = this.#stageIdToSignatures.get(stageId); signatureToStageInstance.set(signature, { + signature, stage: stageInstance, writtenResourcePaths, }); @@ -86,4 +87,13 @@ export default class StageCache { this.#cacheQueue = []; return queue; } + + /** + * Checks if there are pending entries in the cache queue + * + * @returns {boolean} True if there are entries to flush, false otherwise + */ + hasPendingCacheQueue() { + return this.#cacheQueue.length > 0; + } } diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index 4b4bd264a01..adf354197d3 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -247,9 +247,9 @@ export default class HashTree { // Insert the resource const resourceName = parts[parts.length - 1]; - if (current.children.has(resourceName)) { - throw new Error(`Duplicate resource path: ${resourcePath}`); - } + // if (current.children.has(resourceName)) { + // throw new Error(`Duplicate resource path: ${resourcePath}`); + // } const resourceNode = new TreeNode(resourceName, "resource", { integrity: resourceData.integrity, diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index 18f64371d62..ed17e533355 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -49,12 +49,12 @@ export default class ResourceIndex { * signature calculation and change tracking. * * @param {Array<@ui5/fs/Resource>} resources - Resources to index - * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @param {number} indexTimestamp Timestamp at which the provided resources have been indexed + * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise} A new resource index * @public */ - static async create(resources, registry, indexTimestamp) { + static async create(resources, indexTimestamp, registry) { const resourceIndex = await createResourceIndex(resources); const tree = new HashTree(resourceIndex, {registry, indexTimestamp}); return new ResourceIndex(tree); @@ -154,20 +154,6 @@ export default class ResourceIndex { return new ResourceIndex(this.#tree.deriveTree(resourceIndex)); } - // /** - // * Updates existing resources in the index. - // * - // * Updates metadata for resources that already exist in the index. - // * Resources not present in the index are ignored. - // * - // * @param {Array<@ui5/fs/Resource>} resources - Resources to update - // * @returns {Promise} Array of paths for resources that were updated - // * @public - // */ - // async updateResources(resources) { - // return await this.#tree.updateResources(resources); - // } - /** * Compares this index against a base index and returns metadata * for resources that have been added in this index. @@ -188,12 +174,13 @@ export default class ResourceIndex { * - If it exists and hasn't changed, no action is taken * * @param {Array<@ui5/fs/Resource>} resources - Resources to upsert + * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed * @returns {Promise<{added: string[], updated: string[]}>} * Object with arrays of added and updated resource paths * @public */ - async upsertResources(resources) { - return await this.#tree.upsertResources(resources); + async upsertResources(resources, newIndexTimestamp) { + return await this.#tree.upsertResources(resources, newIndexTimestamp); } /** @@ -205,6 +192,10 @@ export default class ResourceIndex { return await this.#tree.removeResources(resourcePaths); } + getResourcePaths() { + return this.#tree.getResourcePaths(); + } + /** * Computes the signature hash for this resource index. * diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 99f62073621..8372b831c49 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -110,8 +110,7 @@ class ProjectBuildContext { return this._requiredDependencies; } const taskRunner = this.getTaskRunner(); - this._requiredDependencies = Array.from(await taskRunner.getRequiredDependencies()) - .sort((a, b) => a.localeCompare(b)); + this._requiredDependencies = await taskRunner.getRequiredDependencies(); return this._requiredDependencies; } @@ -159,60 +158,67 @@ class ProjectBuildContext { } /** - * Determine whether the project has to be built or is already built - * (typically indicated by the presence of a build manifest or a valid cache) + * Early check whether a project build is possibly required. * - * @returns {boolean} True if the project needs to be built + * In some cases, the cache state cannot be determined until all dependencies have been processed and + * the cache has been updated with that information. This happens during prepareProjectBuildAndValidateCache(). + * + * This method allows for an early check whether a project build can be skipped. + * + * @returns {Promise} True if a build might required, false otherwise */ - async requiresBuild() { + async possiblyRequiresBuild() { if (this.#getBuildManifest()) { // Build manifest present -> No build required return false; } - - // Check whether all required dependencies are built and collect their signatures so that - // we can validate our build cache (keyed using the project's sources and relevant dependency signatures) - const depSignatures = []; - const requiredDependencyNames = await this.getRequiredDependencies(); - for (const depName of requiredDependencyNames) { - const depCtx = this._buildContext.getBuildContext(depName); - if (!depCtx) { - throw new Error(`Unexpected missing build context for project '${depName}', dependency of ` + - `project '${this._project.getName()}'`); - } - const signature = await depCtx.getBuildResultSignature(); - if (!signature) { - // Dependency is unable to provide a signature, likely because it needs to be built itself - // Until then, we assume this project requires a build as well and return here - return true; - } - // Collect signatures - depSignatures.push(signature); - } - - return this._buildCache.requiresBuild(depSignatures); - } - - async getBuildResultSignature() { - if (await this.requiresBuild()) { - return null; - } - return await this._buildCache.getResultSignature(); + // Without build manifest, check cache state + return !this.getBuildCache().isFresh(); } - async determineChangedResources() { - return this._buildCache.determineChangedResources(); + /** + * Prepares the project build by updating, and then validating the build cache as needed + * + * @param {boolean} initialBuild + * @returns {Promise} True if project cache is fresh and can be used, false otherwise + */ + async prepareProjectBuildAndValidateCache(initialBuild) { + // if (this.getBuildCache().hasCache() && this.getBuildCache().requiresDependencyIndexInitialization()) { + // const depReader = this.getTaskRunner().getDependenciesReader(this.getTaskRunner.getRequiredDependencies()); + // await this.getBuildCache().updateDependencyCache(depReader); + // } + const depReader = await this.getTaskRunner().getDependenciesReader( + await this.getTaskRunner().getRequiredDependencies(), + true, // Force creation of new reader since project readers might have changed during their (re-)build + ); + this._currentDependencyReader = depReader; + return await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader, initialBuild); } - async runTasks() { + /** + * Builds the project by running all required tasks + * Requires prepareProjectBuildAndValidateCache to be called beforehand + * + * @returns {Promise} Resolves with list of changed resources since the last build + */ + async buildProject() { return await this.getTaskRunner().runTasks(); } - - async projectResourcesChanged(changedPaths) { - return this._buildCache.projectResourcesChanged(changedPaths); + /** + * Informs the build cache about changed project source resources + * + * @param {string[]} changedPaths - Changed project source file paths + */ + projectSourcesChanged(changedPaths) { + return this._buildCache.projectSourcesChanged(changedPaths); } - async dependencyResourcesChanged(changedPaths) { + /** + * Informs the build cache about changed dependency resources + * + * @param {string[]} changedPaths - Changed dependency resource paths + */ + dependencyResourcesChanged(changedPaths) { return this._buildCache.dependencyResourcesChanged(changedPaths); } diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 17d1e6504dd..e85ee1b4e08 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -129,14 +129,12 @@ class WatchHandler extends EventEmitter { } } const projectBuildContext = this.#buildContext.getBuildContext(projectName); - projectBuildContext.getBuildCache() - .projectSourcesChanged(Array.from(changedResourcePaths)); + projectBuildContext.projectSourcesChanged(Array.from(changedResourcePaths)); } for (const [projectName, changedResourcePaths] of dependencyChanges) { const projectBuildContext = this.#buildContext.getBuildContext(projectName); - projectBuildContext.getBuildCache() - .dependencyResourcesChanged(Array.from(changedResourcePaths)); + projectBuildContext.dependencyResourcesChanged(Array.from(changedResourcePaths)); } this.emit("projectResourcesInvalidated"); diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index cbb0f206a53..0dd06e4a290 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -480,9 +480,16 @@ class Project extends Specification { return true; // Indicate that the stored stage has changed } - setResultStage(reader) { + setResultStage(stageOrCacheReader) { this._initStageMetadata(); - const resultStage = new Stage(RESULT_STAGE_ID, undefined, reader); + + let resultStage; + if (stageOrCacheReader instanceof Stage) { + resultStage = stageOrCacheReader; + } else { + resultStage = new Stage(RESULT_STAGE_ID, undefined, stageOrCacheReader); + } + this.#stages.push(resultStage); } From 086c1d180a6d816e9c209ce8ed65ac49d80f5501 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 14 Jan 2026 16:38:06 +0100 Subject: [PATCH 084/223] test(project): Add theme-library test and update assertions for fixed behavior --- .../fixtures/theme.library.e/package.json | 4 + .../lib/build/ProjectBuilder.integration.js | 104 ++++++++++-------- 2 files changed, 64 insertions(+), 44 deletions(-) create mode 100644 packages/project/test/fixtures/theme.library.e/package.json diff --git a/packages/project/test/fixtures/theme.library.e/package.json b/packages/project/test/fixtures/theme.library.e/package.json new file mode 100644 index 00000000000..2315226524d --- /dev/null +++ b/packages/project/test/fixtures/theme.library.e/package.json @@ -0,0 +1,4 @@ +{ + "name": "theme.library.e", + "version": "1.0.0" +} diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 23e06f6597c..b847491e0e4 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -94,20 +94,6 @@ test.serial("Build application.a project multiple times", async (t) => { "library.a": {}, "library.b": {}, "library.c": {}, - - // FIXME: application.a should not be rebuilt here at all. - // Currently it is rebuilt but all tasks are skipped. - "application.a": { - skippedTasks: [ - "enhanceManifest", - "escapeNonAsciiCharacters", - "generateComponentPreload", - "generateFlexChangesBundle", - "minify", - "replaceCopyright", - "replaceVersion", - ] - } } } }); @@ -116,21 +102,7 @@ test.serial("Build application.a project multiple times", async (t) => { await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { - projects: { - // FIXME: application.a should not be rebuilt here at all. - // Currently it is rebuilt but all tasks are skipped. - "application.a": { - skippedTasks: [ - "enhanceManifest", - "escapeNonAsciiCharacters", - "generateComponentPreload", - "generateFlexChangesBundle", - "minify", - "replaceCopyright", - "replaceVersion", - ] - } - } + projects: {} } }); @@ -138,21 +110,7 @@ test.serial("Build application.a project multiple times", async (t) => { await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { - projects: { - // FIXME: application.a should not be rebuilt here at all. - // Currently it is rebuilt but all tasks are skipped. - "application.a": { - skippedTasks: [ - "enhanceManifest", - "escapeNonAsciiCharacters", - "generateComponentPreload", - "generateFlexChangesBundle", - "minify", - "replaceCopyright", - "replaceVersion", - ] - } - } + projects: {} } }); }); @@ -217,6 +175,64 @@ test.serial("Build library.d project multiple times", async (t) => { }); }); +test.serial("Build theme.library.e project multiple times", async (t) => { + const fixtureTester = new FixtureTester(t, "theme.library.e"); + const destPath = fixtureTester.destPath; + + // #1 build (with empty cache) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false}, + assertions: { + projects: {"theme.library.e": {}} + } + }); + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // Change a source file in theme.library.e + const changedFilePath = `${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/library.source.less`; + await fs.appendFile(changedFilePath, `\n.someNewClass {\n\tcolor: red;\n}\n`); + + // #3 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"theme.library.e": {}} + } + }); + + // Check whether the changed file is in the destPath + const builtFileContent = await fs.readFile( + `${destPath}/resources/theme/library/e/themes/my_theme/library.source.less`, {encoding: "utf8"} + ); + t.true( + builtFileContent.includes(`.someNewClass`), + "Build dest contains changed file content" + ); + // Check whether the updated copyright replacement took place + const builtCssContent = await fs.readFile( + `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} + ); + t.true( + builtCssContent.includes(`.someNewClass`), + "Build dest contains new rule in library.css" + ); + + // #4 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } From 34ce53b876d4adde0e46bca1895590e0e9ec64ef Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 15 Jan 2026 15:12:34 +0100 Subject: [PATCH 085/223] test(project): Use graph.build for ProjectBuilder test --- .../test/lib/build/ProjectBuilder.integration.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index b847491e0e4..d0524f5da8d 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2,8 +2,6 @@ import test from "ava"; import sinonGlobal from "sinon"; import {fileURLToPath} from "node:url"; import fs from "node:fs/promises"; -import ProjectBuilder from "../../../lib/build/ProjectBuilder.js"; -import * as taskRepository from "@ui5/builder/internal/taskRepository"; import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; import {setLogLevel} from "@ui5/logger"; @@ -274,22 +272,14 @@ class FixtureTester { const graph = await graphFromPackageDependencies({ cwd: this.fixturePath }); - graph.seal(); - const projectBuilder = new ProjectBuilder({ - graph, - taskRepository, - buildConfig: {} - }); // Execute the build - await projectBuilder.build(config); + await graph.build(config); // Apply assertions if provided if (assertions) { this._assertBuild(assertions); } - - return projectBuilder; } _assertBuild(assertions) { From e8178da16e5e4f797b5079227ef101aa0ec992b8 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 15 Jan 2026 16:00:24 +0100 Subject: [PATCH 086/223] test(project): Add custom task to ProjectBuilder test --- .../fixtures/application.a/task.example.js | 3 ++ .../application.a/ui5-customTask.yaml | 17 ++++++++++ .../lib/build/ProjectBuilder.integration.js | 33 +++++++++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 packages/project/test/fixtures/application.a/task.example.js create mode 100644 packages/project/test/fixtures/application.a/ui5-customTask.yaml diff --git a/packages/project/test/fixtures/application.a/task.example.js b/packages/project/test/fixtures/application.a/task.example.js new file mode 100644 index 00000000000..600405554f4 --- /dev/null +++ b/packages/project/test/fixtures/application.a/task.example.js @@ -0,0 +1,3 @@ +module.exports = function () { + console.log("Example task executed"); +}; diff --git a/packages/project/test/fixtures/application.a/ui5-customTask.yaml b/packages/project/test/fixtures/application.a/ui5-customTask.yaml new file mode 100644 index 00000000000..3c44bbf65c7 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-customTask.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: example-task + afterTask: minify +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: example-task +task: + path: task.example.js diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index d0524f5da8d..6086e2e5e2c 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -111,6 +111,34 @@ test.serial("Build application.a project multiple times", async (t) => { projects: {} } }); + + // #6 build (with cache, no changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #7 build (with cache, no changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // #8 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); }); test.serial("Build library.d project multiple times", async (t) => { @@ -265,12 +293,13 @@ class FixtureTester { this._initialized = true; } - async buildProject({config = {}, assertions = {}} = {}) { + async buildProject({graphConfig = {}, config = {}, assertions = {}} = {}) { await this._initialize(); this._sinon.resetHistory(); const graph = await graphFromPackageDependencies({ - cwd: this.fixturePath + ...graphConfig, + cwd: this.fixturePath, }); // Execute the build From 1cc12e5445a7677781ea3a3659a4e0254d79d78b Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 15 Jan 2026 18:05:04 +0100 Subject: [PATCH 087/223] fix(project): Fix custom task execution The missing log events caused to build to hang when a progress bar was rendered. --- packages/project/lib/build/TaskRunner.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index bc847997f57..49238a1d1ec 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -416,7 +416,7 @@ class TaskRunner { _createCustomTaskWrapper({ project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, task, taskName, taskConfiguration }) { - return async function() { + return async () => { /* Custom Task Interface Parameters: {Object} parameters Parameters @@ -463,7 +463,9 @@ class TaskRunner { if (provideDependenciesReader) { params.dependencies = await getDependenciesReaderCb(); } - return taskFunction(params); + this._log.startTask(taskName, false); + await taskFunction(params); + this._log.endTask(taskName); }; } @@ -480,6 +482,8 @@ class TaskRunner { this._taskStart = performance.now(); await taskFunction(taskParams, this._log); if (this._log.isLevelEnabled("perf")) { + // FIXME: Standard tasks are currently additionally measured within taskFunction (See _addTask). + // The measurement here includes the time for checking whether the task can be skipped via cache. this._log.perf(`Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); } } From 85d1b11a81acf4a71ca3e9e2030b4b55fe6ed7d6 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 16 Jan 2026 10:05:08 +0100 Subject: [PATCH 088/223] fix(project): Prevent writing cache when project build was skipped --- packages/project/lib/build/ProjectBuilder.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 9e6e79efe8e..5db2138de30 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -288,6 +288,7 @@ class ProjectBuilder { } else { if (await projectBuildContext.prepareProjectBuildAndValidateCache(true)) { this.#log.skipProjectBuild(projectName, projectType); + alreadyBuilt.push(projectName); } else { await this._buildProject(projectBuildContext); } From 8772c4e7d43079347fa99b306eaeb32adc28c400 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 15 Jan 2026 16:13:53 +0100 Subject: [PATCH 089/223] refactor(project): Create dedicated SharedHashTree class The HashTree class implemented both working modes: "independent" and "shared". This change moves the "shared" parts into a dedicated subclass --- .../lib/build/cache/ResourceRequestManager.js | 2 +- .../project/lib/build/cache/index/HashTree.js | 251 +------------- .../lib/build/cache/index/ResourceIndex.js | 89 ++++- .../lib/build/cache/index/SharedHashTree.js | 122 +++++++ .../project/lib/build/cache/index/TreeNode.js | 107 ++++++ .../lib/build/cache/index/TreeRegistry.js | 31 +- .../lib/build/cache/index/SharedHashTree.js | 328 ++++++++++++++++++ .../lib/build/cache/index/TreeRegistry.js | 116 ++++--- 8 files changed, 716 insertions(+), 330 deletions(-) create mode 100644 packages/project/lib/build/cache/index/SharedHashTree.js create mode 100644 packages/project/lib/build/cache/index/TreeNode.js create mode 100644 packages/project/test/lib/build/cache/index/SharedHashTree.js diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 6a2fcb05de1..d2d55cbeb3a 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -37,7 +37,7 @@ class ResourceRequestManager { const metadata = requestGraph.getMetadata(nodeId); const registry = resourceRequestManager.#newTreeRegistry(); registries.set(nodeId, registry); - metadata.resourceIndex = ResourceIndex.fromCache(serializedIndex, registry); + metadata.resourceIndex = ResourceIndex.fromCacheShared(serializedIndex, registry); } // Restore delta resource indices if (deltaIndices) { diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index adf354197d3..6f5743d87e1 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import path from "node:path/posix"; +import TreeNode from "./TreeNode.js"; import {matchResourceMetadataStrict} from "../utils.js"; /** @@ -11,119 +12,12 @@ import {matchResourceMetadataStrict} from "../utils.js"; * @property {string} integrity Content hash */ -/** - * Represents a node in the directory-based Merkle tree - */ -class TreeNode { - constructor(name, type, options = {}) { - this.name = name; // resource name or directory name - this.type = type; // 'resource' | 'directory' - this.hash = options.hash || null; // Buffer - - // Resource node properties - this.integrity = options.integrity; // Resource content hash - this.lastModified = options.lastModified; // Last modified timestamp - this.size = options.size; // File size in bytes - this.inode = options.inode; // File system inode number - - // Directory node properties - this.children = options.children || new Map(); // name -> TreeNode - } - - /** - * Get full path from root to this node - * - * @param {string} parentPath - * @returns {string} - */ - getPath(parentPath = "") { - return parentPath ? path.join(parentPath, this.name) : this.name; - } - - /** - * Serialize to JSON - * - * @returns {object} - */ - toJSON() { - const obj = { - name: this.name, - type: this.type, - hash: this.hash ? this.hash.toString("hex") : null - }; - - if (this.type === "resource") { - obj.integrity = this.integrity; - obj.lastModified = this.lastModified; - obj.size = this.size; - obj.inode = this.inode; - } else { - obj.children = {}; - for (const [name, child] of this.children) { - obj.children[name] = child.toJSON(); - } - } - - return obj; - } - - /** - * Deserialize from JSON - * - * @param {object} data - * @returns {TreeNode} - */ - static fromJSON(data) { - const options = { - hash: data.hash ? Buffer.from(data.hash, "hex") : null, - integrity: data.integrity, - lastModified: data.lastModified, - size: data.size, - inode: data.inode - }; - - if (data.type === "directory" && data.children) { - options.children = new Map(); - for (const [name, childData] of Object.entries(data.children)) { - options.children.set(name, TreeNode.fromJSON(childData)); - } - } - - return new TreeNode(data.name, data.type, options); - } - - /** - * Create a deep copy of this node - * - * @returns {TreeNode} - */ - clone() { - const options = { - hash: this.hash ? Buffer.from(this.hash) : null, - integrity: this.integrity, - lastModified: this.lastModified, - size: this.size, - inode: this.inode - }; - - if (this.type === "directory") { - options.children = new Map(); - for (const [name, child] of this.children) { - options.children.set(name, child.clone()); - } - } - - return new TreeNode(this.name, this.type, options); - } -} - /** * Directory-based Merkle Tree for efficient resource tracking with hierarchical structure. * * Computes deterministic SHA256 hashes for resources and directories, enabling: * - Fast change detection via root hash comparison * - Structural sharing through derived trees (memory efficient) - * - Coordinated multi-tree updates via TreeRegistry * - Batch upsert and removal operations * * Primary use case: Build caching systems where multiple related resource trees @@ -137,20 +31,13 @@ export default class HashTree { * @param {Array|null} resources * Initial resources to populate the tree. Each resource should have a path and optional metadata. * @param {object} options - * @param {TreeRegistry} [options.registry] Optional registry for coordinated batch updates across multiple trees * @param {number} [options.indexTimestamp] Timestamp of the latest resource metadata update * @param {TreeNode} [options._root] Internal: pre-existing root node for derived trees (enables structural sharing) */ constructor(resources = null, options = {}) { - this.registry = options.registry || null; this.root = options._root || new TreeNode("", "directory"); this.#indexTimestamp = options.indexTimestamp; - // Register with registry if provided - if (this.registry) { - this.registry.register(this); - } - if (resources && !options._root) { this._buildTree(resources); } else if (resources && options._root) { @@ -411,141 +298,25 @@ export default class HashTree { return current; } - /** - * Create a derived tree that shares subtrees with this tree. - * - * Derived trees are filtered views on shared data - they share node references with the parent tree, - * enabling efficient memory usage. Changes propagate through the TreeRegistry to all derived trees. - * - * Use case: Represent different resource sets (e.g., debug vs. production builds) that share common files. - * - * @param {Array} additionalResources - * Resources to add to the derived tree (in addition to shared resources from parent) - * @returns {HashTree} New tree sharing subtrees with this tree - */ - deriveTree(additionalResources = []) { - // Shallow copy root to allow adding new top-level directories - const derivedRoot = this._shallowCopyDirectory(this.root); - - // Create derived tree with shared root and same registry - const derived = new HashTree(additionalResources, { - registry: this.registry, - _root: derivedRoot - }); - - return derived; - } - - // /** - // * Update multiple resources efficiently. - // * - // * When a registry is attached, schedules updates for batch processing. - // * Otherwise, updates all resources immediately, collecting affected directories - // * and recomputing hashes bottom-up for optimal performance. - // * - // * Skips resources whose metadata hasn't changed (optimization). - // * - // * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to update - // * @returns {Promise>} Paths of resources that actually changed - // */ - // async updateResources(resources) { - // if (!resources || resources.length === 0) { - // return []; - // } - - // const changedResources = []; - // const affectedPaths = new Set(); - - // // Update all resources and collect affected directory paths - // for (const resource of resources) { - // const resourcePath = resource.getOriginalPath(); - // const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); - - // // Find the resource node - // const node = this._findNode(resourcePath); - // if (!node || node.type !== "resource") { - // throw new Error(`Resource not found: ${resourcePath}`); - // } - - // // Create metadata object from current node state - // const currentMetadata = { - // integrity: node.integrity, - // lastModified: node.lastModified, - // size: node.size, - // inode: node.inode - // }; - - // // Check whether resource actually changed - // const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); - // if (isUnchanged) { - // continue; // Skip unchanged resources - // } - - // // Update resource metadata - // node.integrity = await resource.getIntegrity(); - // node.lastModified = resource.getLastModified(); - // node.size = await resource.getSize(); - // node.inode = resource.getInode(); - // changedResources.push(resourcePath); - - // // Recompute resource hash - // this._computeHash(node); - - // // Mark all ancestor directories as needing recomputation - // for (let i = 0; i < parts.length; i++) { - // affectedPaths.add(parts.slice(0, i).join(path.sep)); - // } - // } - - // // Recompute directory hashes bottom-up - // const sortedPaths = Array.from(affectedPaths).sort((a, b) => { - // // Sort by depth (deeper first) and then alphabetically - // const depthA = a.split(path.sep).length; - // const depthB = b.split(path.sep).length; - // if (depthA !== depthB) return depthB - depthA; - // return a.localeCompare(b); - // }); - - // for (const dirPath of sortedPaths) { - // const node = this._findNode(dirPath); - // if (node && node.type === "directory") { - // this._computeHash(node); - // } - // } - - // this._updateIndexTimestamp(); - // return changedResources; - // } - /** * Upsert multiple resources (insert if new, update if exists). * * Intelligently determines whether each resource is new (insert) or existing (update). - * When a registry is attached, schedules operations for batch processing. - * Otherwise, applies operations immediately with optimized hash recomputation. + * Applies operations immediately with optimized hash recomputation. * * Automatically creates missing parent directories during insertion. * Skips resources whose metadata hasn't changed (optimization). * * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to upsert * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed - * @returns {Promise<{added: Array, updated: Array, unchanged: Array}|undefined>} + * @returns {Promise<{added: Array, updated: Array, unchanged: Array}>} * Status report: arrays of paths by operation type. - * Undefined if using registry (results determined during flush). */ async upsertResources(resources, newIndexTimestamp) { if (!resources || resources.length === 0) { return {added: [], updated: [], unchanged: []}; } - if (this.registry) { - for (const resource of resources) { - this.registry.scheduleUpsert(resource, newIndexTimestamp); - } - // When using registry, actual results are determined during flush - return; - } - // Immediate mode const added = []; const updated = []; @@ -629,29 +400,17 @@ export default class HashTree { /** * Remove multiple resources efficiently. * - * When a registry is attached, schedules removals for batch processing. - * Otherwise, removes resources immediately and recomputes affected ancestor hashes. - * - * Note: When using a registry with derived trees, removals propagate to all trees - * sharing the affected directories (intentional for the shared view model). + * Removes resources immediately and recomputes affected ancestor hashes. * * @param {Array} resourcePaths - Array of resource paths to remove - * @returns {Promise<{removed: Array, notFound: Array}|undefined>} + * @returns {Promise<{removed: Array, notFound: Array}>} * Status report: 'removed' contains successfully removed paths, 'notFound' contains paths that didn't exist. - * Undefined if using registry (results determined during flush). */ async removeResources(resourcePaths) { if (!resourcePaths || resourcePaths.length === 0) { return {removed: [], notFound: []}; } - if (this.registry) { - for (const resourcePath of resourcePaths) { - this.registry.scheduleRemoval(resourcePath); - } - return; - } - // Immediate mode const removed = []; const notFound = []; diff --git a/packages/project/lib/build/cache/index/ResourceIndex.js b/packages/project/lib/build/cache/index/ResourceIndex.js index ed17e533355..e345922da6b 100644 --- a/packages/project/lib/build/cache/index/ResourceIndex.js +++ b/packages/project/lib/build/cache/index/ResourceIndex.js @@ -6,6 +6,7 @@ * enabling fast delta detection and signature calculation for build caching. */ import HashTree from "./HashTree.js"; +import SharedHashTree from "./SharedHashTree.js"; import {createResourceIndex} from "../utils.js"; /** @@ -50,13 +51,31 @@ export default class ResourceIndex { * * @param {Array<@ui5/fs/Resource>} resources - Resources to index * @param {number} indexTimestamp Timestamp at which the provided resources have been indexed - * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise} A new resource index * @public */ - static async create(resources, indexTimestamp, registry) { + static async create(resources, indexTimestamp) { const resourceIndex = await createResourceIndex(resources); - const tree = new HashTree(resourceIndex, {registry, indexTimestamp}); + const tree = new HashTree(resourceIndex, {indexTimestamp}); + return new ResourceIndex(tree); + } + + /** + * Creates a new shared ResourceIndex from a set of resources. + * + * Creates a SharedHashTree that coordinates updates through a TreeRegistry. + * Use this for scenarios where multiple indices need to share nodes and + * coordinate batch updates. + * + * @param {Array<@ui5/fs/Resource>} resources - Resources to index + * @param {number} indexTimestamp Timestamp at which the provided resources have been indexed + * @param {TreeRegistry} registry - Registry for coordinated batch updates + * @returns {Promise} A new resource index with shared tree + * @public + */ + static async createShared(resources, indexTimestamp, registry) { + const resourceIndex = await createResourceIndex(resources); + const tree = new SharedHashTree(resourceIndex, registry, {indexTimestamp}); return new ResourceIndex(tree); } @@ -76,14 +95,13 @@ export default class ResourceIndex { * @param {object} indexCache.indexTree - Cached hash tree structure * @param {Array<@ui5/fs/Resource>} resources - Current resources to compare against cache * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed - * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees * @returns {Promise<{changedPaths: string[], resourceIndex: ResourceIndex}>} * Object containing array of all changed resource paths and the updated index * @public */ - static async fromCacheWithDelta(indexCache, resources, newIndexTimestamp, registry) { + static async fromCacheWithDelta(indexCache, resources, newIndexTimestamp) { const {indexTimestamp, indexTree} = indexCache; - const tree = HashTree.fromCache(indexTree, {indexTimestamp, registry}); + const tree = HashTree.fromCache(indexTree, {indexTimestamp}); const currentResourcePaths = new Set(resources.map((resource) => resource.getOriginalPath())); const removedPaths = tree.getResourcePaths().filter((resourcePath) => { return !currentResourcePaths.has(resourcePath); @@ -96,6 +114,39 @@ export default class ResourceIndex { }; } + /** + * Restores a shared ResourceIndex from cache and applies delta updates. + * + * Same as fromCacheWithDelta, but creates a SharedHashTree that coordinates + * updates through a TreeRegistry. + * + * @param {object} indexCache - Cached index object from previous build + * @param {number} indexCache.indexTimestamp - Timestamp of cached index + * @param {object} indexCache.indexTree - Cached hash tree structure + * @param {Array<@ui5/fs/Resource>} resources - Current resources to compare against cache + * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed + * @param {TreeRegistry} registry - Registry for coordinated batch updates + * @returns {Promise<{changedPaths: string[], resourceIndex: ResourceIndex}>} + * Object containing array of all changed resource paths and the updated index + * @public + */ + static async fromCacheWithDeltaShared(indexCache, resources, newIndexTimestamp, registry) { + const {indexTimestamp, indexTree} = indexCache; + const tree = SharedHashTree.fromCache(indexTree, registry, {indexTimestamp}); + const currentResourcePaths = new Set(resources.map((resource) => resource.getOriginalPath())); + const removedPaths = tree.getResourcePaths().filter((resourcePath) => { + return !currentResourcePaths.has(resourcePath); + }); + await tree.removeResources(removedPaths); + await tree.upsertResources(resources, newIndexTimestamp); + // For shared trees, we need to flush the registry to get results + const {added, updated, removed} = await registry.flush(); + return { + changedPaths: [...added, ...updated, ...removed], + resourceIndex: new ResourceIndex(tree), + }; + } + /** * Restores a ResourceIndex from cached metadata. * @@ -106,13 +157,31 @@ export default class ResourceIndex { * @param {object} indexCache - Cached index object * @param {number} indexCache.indexTimestamp - Timestamp of cached index * @param {object} indexCache.indexTree - Cached hash tree structure - * @param {TreeRegistry} [registry] - Optional tree registry for structural sharing within trees - * @returns {Promise} Restored resource index + * @returns {ResourceIndex} Restored resource index + * @public + */ + static fromCache(indexCache) { + const {indexTimestamp, indexTree} = indexCache; + const tree = HashTree.fromCache(indexTree, {indexTimestamp}); + return new ResourceIndex(tree); + } + + /** + * Restores a shared ResourceIndex from cached metadata. + * + * Same as fromCache, but creates a SharedHashTree that coordinates + * updates through a TreeRegistry. + * + * @param {object} indexCache - Cached index object + * @param {number} indexCache.indexTimestamp - Timestamp of cached index + * @param {object} indexCache.indexTree - Cached hash tree structure + * @param {TreeRegistry} registry - Registry for coordinated batch updates + * @returns {ResourceIndex} Restored resource index with shared tree * @public */ - static fromCache(indexCache, registry) { + static fromCacheShared(indexCache, registry) { const {indexTimestamp, indexTree} = indexCache; - const tree = HashTree.fromCache(indexTree, {indexTimestamp, registry}); + const tree = SharedHashTree.fromCache(indexTree, registry, {indexTimestamp}); return new ResourceIndex(tree); } diff --git a/packages/project/lib/build/cache/index/SharedHashTree.js b/packages/project/lib/build/cache/index/SharedHashTree.js new file mode 100644 index 00000000000..153b12b5d5c --- /dev/null +++ b/packages/project/lib/build/cache/index/SharedHashTree.js @@ -0,0 +1,122 @@ +import HashTree from "./HashTree.js"; +import TreeNode from "./TreeNode.js"; + +/** + * Shared HashTree that coordinates updates through a TreeRegistry. + * + * This variant of HashTree is designed for scenarios where multiple trees need + * to share nodes and coordinate batch updates. All modifications (upserts and removals) + * are delegated to the registry, which applies them atomically across all registered trees. + * + * Key differences from base HashTree: + * - Requires a TreeRegistry instance + * - upsertResources() and removeResources() return undefined (results available via registry.flush()) + * - Derived trees share the same registry + * - Changes to shared nodes propagate to all trees + * + * @extends HashTree + */ +export default class SharedHashTree extends HashTree { + /** + * Create a new SharedHashTree + * + * @param {Array|null} resources + * Initial resources to populate the tree. Each resource should have a path and optional metadata. + * @param {TreeRegistry} registry Required registry for coordinated batch updates across multiple trees + * @param {object} options + * @param {number} [options.indexTimestamp] Timestamp of the latest resource metadata update + * @param {TreeNode} [options._root] Internal: pre-existing root node for derived trees (enables structural sharing) + */ + constructor(resources = null, registry, options = {}) { + if (!registry) { + throw new Error("SharedHashTree requires a registry option"); + } + + super(resources, options); + + this.registry = registry; + this.registry.register(this); + } + + /** + * Schedule resource upserts (insert or update) to be applied during registry flush. + * + * Unlike base HashTree, this method doesn't immediately modify the tree. + * Instead, it schedules operations with the registry for batch processing. + * Call registry.flush() to apply all pending operations atomically. + * + * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to upsert + * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed + * @returns {Promise} Returns undefined; results available via registry.flush() + */ + async upsertResources(resources, newIndexTimestamp) { + if (!resources || resources.length === 0) { + return; + } + + for (const resource of resources) { + this.registry.scheduleUpsert(resource, newIndexTimestamp); + } + } + + /** + * Schedule resource removals to be applied during registry flush. + * + * Unlike base HashTree, this method doesn't immediately modify the tree. + * Instead, it schedules operations with the registry for batch processing. + * Call registry.flush() to apply all pending operations atomically. + * + * @param {Array} resourcePaths - Array of resource paths to remove + * @returns {Promise} Returns undefined; results available via registry.flush() + */ + async removeResources(resourcePaths) { + if (!resourcePaths || resourcePaths.length === 0) { + return; + } + + for (const resourcePath of resourcePaths) { + this.registry.scheduleRemoval(resourcePath); + } + } + + /** + * Create a derived shared tree that shares subtrees with this tree. + * + * The derived tree shares the same registry and will participate in + * coordinated batch updates. Changes to shared nodes propagate to all trees. + * + * @param {Array} additionalResources + * Resources to add to the derived tree (in addition to shared resources from parent) + * @returns {SharedHashTree} New shared tree sharing subtrees and registry with this tree + */ + deriveTree(additionalResources = []) { + // Shallow copy root to allow adding new top-level directories + const derivedRoot = this._shallowCopyDirectory(this.root); + + // Create derived tree with shared root and same registry + const derived = new SharedHashTree(additionalResources, this.registry, { + _root: derivedRoot + }); + + return derived; + } + + /** + * Deserialize tree from JSON + * + * @param {object} data + * @param {TreeRegistry} registry Required registry for coordinated batch updates across multiple trees + * @param {object} [options] + * @returns {HashTree} + */ + static fromCache(data, registry, options = {}) { + if (data.version !== 1) { + throw new Error(`Unsupported version: ${data.version}`); + } + + const tree = new SharedHashTree(null, registry, options); + tree.root = TreeNode.fromJSON(data.root); + + return tree; + } +} diff --git a/packages/project/lib/build/cache/index/TreeNode.js b/packages/project/lib/build/cache/index/TreeNode.js new file mode 100644 index 00000000000..130e9ef9152 --- /dev/null +++ b/packages/project/lib/build/cache/index/TreeNode.js @@ -0,0 +1,107 @@ +import path from "node:path/posix"; + +/** + * Represents a node in the directory-based Merkle tree + */ +export default class TreeNode { + constructor(name, type, options = {}) { + this.name = name; // resource name or directory name + this.type = type; // 'resource' | 'directory' + this.hash = options.hash || null; // Buffer + + // Resource node properties + this.integrity = options.integrity; // Resource content hash + this.lastModified = options.lastModified; // Last modified timestamp + this.size = options.size; // File size in bytes + this.inode = options.inode; // File system inode number + + // Directory node properties + this.children = options.children || new Map(); // name -> TreeNode + } + + /** + * Get full path from root to this node + * + * @param {string} parentPath + * @returns {string} + */ + getPath(parentPath = "") { + return parentPath ? path.join(parentPath, this.name) : this.name; + } + + /** + * Serialize to JSON + * + * @returns {object} + */ + toJSON() { + const obj = { + name: this.name, + type: this.type, + hash: this.hash ? this.hash.toString("hex") : null + }; + + if (this.type === "resource") { + obj.integrity = this.integrity; + obj.lastModified = this.lastModified; + obj.size = this.size; + obj.inode = this.inode; + } else { + obj.children = {}; + for (const [name, child] of this.children) { + obj.children[name] = child.toJSON(); + } + } + + return obj; + } + + /** + * Deserialize from JSON + * + * @param {object} data + * @returns {TreeNode} + */ + static fromJSON(data) { + const options = { + hash: data.hash ? Buffer.from(data.hash, "hex") : null, + integrity: data.integrity, + lastModified: data.lastModified, + size: data.size, + inode: data.inode + }; + + if (data.type === "directory" && data.children) { + options.children = new Map(); + for (const [name, childData] of Object.entries(data.children)) { + options.children.set(name, TreeNode.fromJSON(childData)); + } + } + + return new TreeNode(data.name, data.type, options); + } + + /** + * Create a deep copy of this node + * + * @returns {TreeNode} + */ + clone() { + const options = { + hash: this.hash ? Buffer.from(this.hash) : null, + integrity: this.integrity, + lastModified: this.lastModified, + size: this.size, + inode: this.inode + }; + + if (this.type === "directory") { + options.children = new Map(); + for (const [name, child] of this.children) { + options.children.set(name, child.clone()); + } + } + + return new TreeNode(this.name, this.type, options); + } +} diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index cba02d73729..a127e36db90 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -1,4 +1,5 @@ import path from "node:path/posix"; +import TreeNode from "./TreeNode.js"; import {matchResourceMetadataStrict} from "../utils.js"; /** @@ -15,7 +16,7 @@ import {matchResourceMetadataStrict} from "../utils.js"; * * This approach ensures consistency when multiple trees represent filtered views of the same underlying data. * - * @property {Set} trees - All registered HashTree instances + * @property {Set} trees - All registered HashTree/SharedHashTree instances * @property {Map} pendingUpserts - Resource path to resource mappings for scheduled upserts * @property {Set} pendingRemovals - Resource paths scheduled for removal */ @@ -26,24 +27,24 @@ export default class TreeRegistry { pendingTimestampUpdate; /** - * Register a HashTree instance with this registry for coordinated updates. + * Register a HashTree or SharedHashTree instance with this registry for coordinated updates. * * Once registered, the tree will participate in all batch operations triggered by flush(). * Multiple trees can share the same underlying nodes through structural sharing. * - * @param {import('./HashTree.js').default} tree - HashTree instance to register + * @param {import('./SharedHashTree.js').default} tree - HashTree or SharedHashTree instance to register */ register(tree) { this.trees.add(tree); } /** - * Remove a HashTree instance from this registry. + * Remove a HashTree or SharedHashTree instance from this registry. * * After unregistering, the tree will no longer participate in batch operations. * Any pending operations scheduled before unregistration will still be applied during flush(). * - * @param {import('./HashTree.js').default} tree - HashTree instance to unregister + * @param {import('./SharedHashTree.js').default} tree - HashTree or SharedHashTree instance to unregister */ unregister(tree) { this.trees.delete(tree); @@ -106,7 +107,7 @@ export default class TreeRegistry { * After successful completion, all pending operations are cleared. * * @returns {Promise<{added: string[], updated: string[], unchanged: string[], removed: string[], - * treeStats: Map}>} * Object containing arrays of resource paths categorized by operation result, * plus per-tree statistics showing which resource paths were added/updated/unchanged/removed in each tree @@ -233,7 +234,6 @@ export default class TreeRegistry { if (!resourceNode) { // INSERT: Create new resource node - const TreeNode = tree.root.constructor; resourceNode = new TreeNode(upsert.resourceName, "resource", { integrity: await upsert.resource.getIntegrity(), lastModified: upsert.resource.getLastModified(), @@ -358,10 +358,9 @@ export default class TreeRegistry { * Returns an array of TreeNode objects representing the full path, * starting with root at index 0 and ending with the target node. * - * @param {import('./HashTree.js').default} tree - Tree to traverse + * @param {import('./SharedHashTree.js').default} tree - Tree to traverse * @param {string[]} pathParts - Path components to follow - * @returns {Array} Array of TreeNode objects along the path - * @private + * @returns {Array} Array of TreeNode objects along the path */ _getPathNodes(tree, pathParts) { const nodes = [tree.root]; @@ -385,10 +384,10 @@ export default class TreeRegistry { * need their hashes recomputed to reflect the change. This method tracks those paths * in the affectedTrees map for later batch processing. * - * @param {import('./HashTree.js').default} tree - Tree containing the affected path + * @param {import('./SharedHashTree.js').default} tree - Tree containing the affected path * @param {string[]} pathParts - Path components of the modified resource/directory - * @param {Map>} affectedTrees - Map tracking affected paths per tree - * @private + * @param {Map>} affectedTrees + * Map tracking affected paths per tree */ _markAncestorsAffected(tree, pathParts, affectedTrees) { if (!affectedTrees.has(tree)) { @@ -407,14 +406,12 @@ export default class TreeRegistry { * It's used during upsert operations to automatically create parent directories * when inserting resources into paths that don't yet exist. * - * @param {import('./HashTree.js').default} tree - Tree to create directory path in + * @param {import('./SharedHashTree.js').default} tree - Tree to create directory path in * @param {string[]} pathParts - Path components of the directory to ensure exists - * @returns {object} The directory node at the end of the path - * @private + * @returns {TreeNode} The directory node at the end of the path */ _ensureDirectoryPath(tree, pathParts) { let current = tree.root; - const TreeNode = tree.root.constructor; for (const part of pathParts) { if (!current.children.has(part)) { diff --git a/packages/project/test/lib/build/cache/index/SharedHashTree.js b/packages/project/test/lib/build/cache/index/SharedHashTree.js new file mode 100644 index 00000000000..b5b265d78ee --- /dev/null +++ b/packages/project/test/lib/build/cache/index/SharedHashTree.js @@ -0,0 +1,328 @@ +import test from "ava"; +import sinon from "sinon"; +import SharedHashTree from "../../../../../lib/build/cache/index/SharedHashTree.js"; +import TreeRegistry from "../../../../../lib/build/cache/index/TreeRegistry.js"; + +// Helper to create mock Resource instances +function createMockResource(path, integrity, lastModified, size, inode) { + return { + getOriginalPath: () => path, + getIntegrity: async () => integrity, + getLastModified: () => lastModified, + getSize: async () => size, + getInode: () => inode + }; +} + +test.afterEach.always((t) => { + sinon.restore(); +}); + +// ============================================================================ +// SharedHashTree Construction Tests +// ============================================================================ + +test("SharedHashTree - requires registry option", (t) => { + t.throws(() => { + new SharedHashTree([{path: "a.js", integrity: "hash1"}]); + }, { + message: "SharedHashTree requires a registry option" + }, "Should throw error when registry is missing"); +}); + +test("SharedHashTree - auto-registers with registry", (t) => { + const registry = new TreeRegistry(); + new SharedHashTree([{path: "a.js", integrity: "hash1"}], registry); + + t.is(registry.getTreeCount(), 1, "Should auto-register with registry"); +}); + +test("SharedHashTree - creates tree with resources", (t) => { + const registry = new TreeRegistry(); + const resources = [ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ]; + const tree = new SharedHashTree(resources, registry); + + t.truthy(tree.getRootHash(), "Should have root hash"); + t.true(tree.hasPath("a.js"), "Should have a.js"); + t.true(tree.hasPath("b.js"), "Should have b.js"); +}); + +// ============================================================================ +// SharedHashTree upsertResources Tests +// ============================================================================ + +test("SharedHashTree - upsertResources schedules with registry", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + const resource = createMockResource("b.js", "hash-b", Date.now(), 1024, 1); + const result = await tree.upsertResources([resource], Date.now()); + + t.is(result, undefined, "Should return undefined (scheduled mode)"); + t.is(registry.getPendingUpdateCount(), 1, "Should schedule upsert with registry"); +}); + +test("SharedHashTree - upsertResources with empty array returns immediately", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + const result = await tree.upsertResources([], Date.now()); + + t.is(result, undefined, "Should return undefined"); + t.is(registry.getPendingUpdateCount(), 0, "Should not schedule anything"); +}); + +test("SharedHashTree - multiple upserts are batched", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + await tree.upsertResources([createMockResource("b.js", "hash-b", Date.now(), 1024, 1)], Date.now()); + await tree.upsertResources([createMockResource("c.js", "hash-c", Date.now(), 2048, 2)], Date.now()); + + t.is(registry.getPendingUpdateCount(), 2, "Should have 2 pending upserts"); + + const result = await registry.flush(); + t.deepEqual(result.added.sort(), ["b.js", "c.js"], "Should add both resources"); +}); + +// ============================================================================ +// SharedHashTree removeResources Tests +// ============================================================================ + +test("SharedHashTree - removeResources schedules with registry", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ], registry); + + const result = await tree.removeResources(["b.js"]); + + t.is(result, undefined, "Should return undefined (scheduled mode)"); + t.is(registry.getPendingUpdateCount(), 1, "Should schedule removal with registry"); +}); + +test("SharedHashTree - removeResources with empty array returns immediately", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + const result = await tree.removeResources([]); + + t.is(result, undefined, "Should return undefined"); + t.is(registry.getPendingUpdateCount(), 0, "Should not schedule anything"); +}); + +test("SharedHashTree - multiple removals are batched", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"}, + {path: "c.js", integrity: "hash-c"} + ], registry); + + await tree.removeResources(["b.js"]); + await tree.removeResources(["c.js"]); + + t.is(registry.getPendingUpdateCount(), 2, "Should have 2 pending removals"); + + const result = await registry.flush(); + t.deepEqual(result.removed.sort(), ["b.js", "c.js"], "Should remove both resources"); +}); + +// ============================================================================ +// SharedHashTree deriveTree Tests +// ============================================================================ + +test("SharedHashTree - deriveTree creates SharedHashTree", (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + const tree2 = tree1.deriveTree([{path: "b.js", integrity: "hash-b"}]); + + t.true(tree2 instanceof SharedHashTree, "Derived tree should be SharedHashTree"); + t.is(tree2.registry, registry, "Derived tree should share same registry"); +}); + +test("SharedHashTree - deriveTree registers derived tree", (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + t.is(registry.getTreeCount(), 1, "Should have 1 tree initially"); + + tree1.deriveTree([{path: "b.js", integrity: "hash-b"}]); + + t.is(registry.getTreeCount(), 2, "Should have 2 trees after derivation"); +}); + +test("SharedHashTree - deriveTree shares nodes with parent", (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ], registry); + + const tree2 = tree1.deriveTree([{path: "unique/c.js", integrity: "hash-c"}]); + + // Verify they share the "shared" directory node + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + + t.is(sharedDir1, sharedDir2, "Should share the same 'shared' directory node"); +}); + +test("SharedHashTree - deriveTree with empty resources", (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + const tree2 = tree1.deriveTree([]); + + t.is(tree1.getRootHash(), tree2.getRootHash(), "Empty derivation should have same hash"); + t.true(tree2 instanceof SharedHashTree, "Should be SharedHashTree"); +}); + +// ============================================================================ +// SharedHashTree with Registry Integration Tests +// ============================================================================ + +test("SharedHashTree - changes via registry affect tree", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + + const originalHash = tree.getRootHash(); + + await tree.upsertResources([createMockResource("b.js", "hash-b", Date.now(), 1024, 1)], Date.now()); + await registry.flush(); + + const newHash = tree.getRootHash(); + t.not(originalHash, newHash, "Root hash should change after flush"); + t.true(tree.hasPath("b.js"), "Tree should have new resource"); +}); + +test("SharedHashTree - batch updates via registry", async (t) => { + const registry = new TreeRegistry(); + const tree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a", lastModified: 1000, size: 100} + ], registry); + + const indexTimestamp = tree.getIndexTimestamp(); + + // Schedule multiple operations + await tree.upsertResources([createMockResource("b.js", "hash-b", Date.now(), 1024, 1)], Date.now()); + await tree.upsertResources([createMockResource("a.js", "new-hash-a", indexTimestamp + 1, 101, 1)], Date.now()); + + const result = await registry.flush(); + + t.deepEqual(result.added, ["b.js"], "Should add b.js"); + t.deepEqual(result.updated, ["a.js"], "Should update a.js"); +}); + +test("SharedHashTree - multiple trees coordinate via registry", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([ + {path: "shared/a.js", integrity: "hash-a"} + ], registry); + + const tree2 = tree1.deriveTree([{path: "unique/b.js", integrity: "hash-b"}]); + + // Verify they share directory nodes + const sharedDir1Before = tree1.root.children.get("shared"); + const sharedDir2Before = tree2.root.children.get("shared"); + t.is(sharedDir1Before, sharedDir2Before, "Should share nodes before update"); + + // Update shared resource via tree1 + const indexTimestamp = tree1.getIndexTimestamp(); + await tree1.upsertResources([ + createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) + ], Date.now()); + + await registry.flush(); + + // Both trees see the change + const node1 = tree1.root.children.get("shared").children.get("a.js"); + const node2 = tree2.root.children.get("shared").children.get("a.js"); + + t.is(node1, node2, "Should share same resource node"); + t.is(node1.integrity, "new-hash-a", "Tree1 sees update"); + t.is(node2.integrity, "new-hash-a", "Tree2 sees update (shared node)"); +}); + +test("SharedHashTree - registry tracks per-tree statistics", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + const tree2 = new SharedHashTree([{path: "b.js", integrity: "hash-b"}], registry); + + await tree1.upsertResources([createMockResource("c.js", "hash-c", Date.now(), 1024, 1)], Date.now()); + await tree2.upsertResources([createMockResource("d.js", "hash-d", Date.now(), 2048, 2)], Date.now()); + + const result = await registry.flush(); + + t.is(result.treeStats.size, 2, "Should have stats for 2 trees"); + // Each tree sees additions for resources added by any tree (since all trees get all resources) + const stats1 = result.treeStats.get(tree1); + const stats2 = result.treeStats.get(tree2); + + // Both c.js and d.js are added to both trees + t.deepEqual(stats1.added.sort(), ["c.js", "d.js"], "Tree1 should see both additions"); + t.deepEqual(stats2.added.sort(), ["c.js", "d.js"], "Tree2 should see both additions"); +}); + +test("SharedHashTree - unregister removes tree from coordination", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + new SharedHashTree([{path: "b.js", integrity: "hash-b"}], registry); + + t.is(registry.getTreeCount(), 2, "Should have 2 trees"); + + registry.unregister(tree1); + + t.is(registry.getTreeCount(), 1, "Should have 1 tree after unregister"); + + // Operations on tree1 no longer coordinated + await tree1.upsertResources([createMockResource("c.js", "hash-c", Date.now(), 1024, 1)], Date.now()); + const result = await registry.flush(); + + // tree1 not in results since it's unregistered + t.is(result.treeStats.size, 1, "Should only have stats for tree2"); + t.false(result.treeStats.has(tree1), "Should not have stats for unregistered tree1"); +}); + +test("SharedHashTree - complex multi-tree coordination", async (t) => { + const registry = new TreeRegistry(); + + // Create base tree + const baseTree = new SharedHashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ], registry); + + // Derive two trees from base + const derived1 = baseTree.deriveTree([{path: "d1/c.js", integrity: "hash-c"}]); + const derived2 = baseTree.deriveTree([{path: "d2/d.js", integrity: "hash-d"}]); + + t.is(registry.getTreeCount(), 3, "Should have 3 trees"); + + // Schedule updates to shared resource + const indexTimestamp = baseTree.getIndexTimestamp(); + await baseTree.upsertResources([ + createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) + ], Date.now()); + + const result = await registry.flush(); + + // All trees see the update + t.deepEqual(result.treeStats.get(baseTree).updated, ["shared/a.js"]); + t.deepEqual(result.treeStats.get(derived1).updated, ["shared/a.js"]); + t.deepEqual(result.treeStats.get(derived2).updated, ["shared/a.js"]); + + // Verify shared nodes + const sharedA1 = baseTree.root.children.get("shared").children.get("a.js"); + const sharedA2 = derived1.root.children.get("shared").children.get("a.js"); + const sharedA3 = derived2.root.children.get("shared").children.get("a.js"); + + t.is(sharedA1, sharedA2, "baseTree and derived1 share node"); + t.is(sharedA1, sharedA3, "baseTree and derived2 share node"); + t.is(sharedA1.integrity, "new-hash-a", "All see updated value"); +}); diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js index 41e8f1ece93..ff110d0d6fc 100644 --- a/packages/project/test/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -1,6 +1,6 @@ import test from "ava"; import sinon from "sinon"; -import HashTree from "../../../../../lib/build/cache/index/HashTree.js"; +import SharedHashTree from "../../../../../lib/build/cache/index/SharedHashTree.js"; import TreeRegistry from "../../../../../lib/build/cache/index/TreeRegistry.js"; // Helper to create mock Resource instances @@ -24,8 +24,8 @@ test.afterEach.always((t) => { test("TreeRegistry - register and track trees", (t) => { const registry = new TreeRegistry(); - new HashTree([{path: "a.js", integrity: "hash1"}], {registry}); - new HashTree([{path: "b.js", integrity: "hash2"}], {registry}); + new SharedHashTree([{path: "a.js", integrity: "hash1"}], registry); + new SharedHashTree([{path: "b.js", integrity: "hash2"}], registry); t.is(registry.getTreeCount(), 2, "Should track both trees"); }); @@ -33,7 +33,7 @@ test("TreeRegistry - register and track trees", (t) => { test("TreeRegistry - schedule and flush updates", async (t) => { const registry = new TreeRegistry(); const resources = [{path: "file.js", integrity: "hash1"}]; - const tree = new HashTree(resources, {registry}); + const tree = new SharedHashTree(resources, registry); const originalHash = tree.getRootHash(); @@ -56,7 +56,7 @@ test("TreeRegistry - flush returns only changed resources", async (t) => { {path: "file1.js", integrity: "hash1", lastModified: timestamp, size: 1024, inode: 123}, {path: "file2.js", integrity: "hash2", lastModified: timestamp, size: 2048, inode: 124} ]; - new HashTree(resources, {registry}); + new SharedHashTree(resources, registry); registry.scheduleUpsert(createMockResource("file1.js", "new-hash1", timestamp, 1024, 123)); registry.scheduleUpsert(createMockResource("file2.js", "hash2", timestamp, 2048, 124)); // unchanged @@ -69,7 +69,7 @@ test("TreeRegistry - flush returns empty array when no changes", async (t) => { const registry = new TreeRegistry(); const timestamp = Date.now(); const resources = [{path: "file.js", integrity: "hash1", lastModified: timestamp, size: 1024, inode: 123}]; - new HashTree(resources, {registry}); + new SharedHashTree(resources, registry); registry.scheduleUpsert(createMockResource("file.js", "hash1", timestamp, 1024, 123)); // same value @@ -84,7 +84,7 @@ test("TreeRegistry - batch updates affect all trees sharing nodes", async (t) => {path: "shared/b.js", integrity: "hash-b"} ]; - const tree1 = new HashTree(resources, {registry}); + const tree1 = new SharedHashTree(resources, registry); const originalHash1 = tree1.getRootHash(); // Create derived tree that shares "shared" directory @@ -120,7 +120,7 @@ test("TreeRegistry - batch updates affect all trees sharing nodes", async (t) => test("TreeRegistry - handles missing resources gracefully during flush", async (t) => { const registry = new TreeRegistry(); - new HashTree([{path: "exists.js", integrity: "hash1"}], {registry}); + new SharedHashTree([{path: "exists.js", integrity: "hash1"}], registry); // Schedule update for non-existent resource registry.scheduleUpsert(createMockResource("missing.js", "hash2", Date.now(), 1024, 444)); @@ -131,7 +131,7 @@ test("TreeRegistry - handles missing resources gracefully during flush", async ( test("TreeRegistry - multiple updates to same resource", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([{path: "file.js", integrity: "v1"}], {registry}); + const tree = new SharedHashTree([{path: "file.js", integrity: "v1"}], registry); const timestamp = Date.now(); registry.scheduleUpsert(createMockResource("file.js", "v2", timestamp, 1024, 100)); @@ -149,13 +149,13 @@ test("TreeRegistry - multiple updates to same resource", async (t) => { test("TreeRegistry - updates without changes lead to same hash", async (t) => { const registry = new TreeRegistry(); const timestamp = Date.now(); - const tree = new HashTree([{ + const tree = new SharedHashTree([{ path: "/src/foo/file1.js", integrity: "v1", }, { path: "/src/foo/file3.js", integrity: "v1", }, { path: "/src/foo/file2.js", integrity: "v1", - }], {registry}); + }], registry); const initialHash = tree.getRootHash(); const file2Hash = tree.getResourceByPath("/src/foo/file2.js").hash; @@ -173,8 +173,8 @@ test("TreeRegistry - updates without changes lead to same hash", async (t) => { test("TreeRegistry - unregister tree", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([{path: "a.js", integrity: "hash1"}], {registry}); - const tree2 = new HashTree([{path: "b.js", integrity: "hash2"}], {registry}); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash1"}], registry); + const tree2 = new SharedHashTree([{path: "b.js", integrity: "hash2"}], registry); t.is(registry.getTreeCount(), 2); @@ -193,12 +193,13 @@ test("TreeRegistry - unregister tree", async (t) => { // ============================================================================ test("deriveTree - creates tree sharing subtrees", (t) => { + const registry = new TreeRegistry(); const resources = [ {path: "dir1/a.js", integrity: "hash-a"}, {path: "dir1/b.js", integrity: "hash-b"} ]; - const tree1 = new HashTree(resources); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([{path: "dir2/c.js", integrity: "hash-c"}]); // Both trees should have dir1 @@ -210,11 +211,12 @@ test("deriveTree - creates tree sharing subtrees", (t) => { }); test("deriveTree - shared nodes are the same reference", (t) => { + const registry = new TreeRegistry(); const resources = [ {path: "shared/file.js", integrity: "hash1"} ]; - const tree1 = new HashTree(resources); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([]); // Get the shared directory node from both trees @@ -236,7 +238,7 @@ test("deriveTree - updates to shared nodes visible in all trees", async (t) => { {path: "shared/file.js", integrity: "original"} ]; - const tree1 = new HashTree(resources, {registry}); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([]); // Get nodes before update @@ -258,7 +260,7 @@ test("deriveTree - updates to shared nodes visible in all trees", async (t) => { test("deriveTree - multiple levels of derivation", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); const tree2 = tree1.deriveTree([{path: "b.js", integrity: "hash-b"}]); const tree3 = tree2.deriveTree([{path: "c.js", integrity: "hash-c"}]); @@ -284,7 +286,7 @@ test("deriveTree - efficient hash recomputation", async (t) => { {path: "dir2/c.js", integrity: "hash-c"} ]; - const tree1 = new HashTree(resources, {registry}); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([{path: "dir3/d.js", integrity: "hash-d"}]); // Spy on _computeHash to count calls @@ -307,7 +309,7 @@ test("deriveTree - independent updates to different directories", async (t) => { {path: "dir1/a.js", integrity: "hash-a"} ]; - const tree1 = new HashTree(resources, {registry}); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([{path: "dir2/b.js", integrity: "hash-b"}]); const hash1Before = tree1.getRootHash(); @@ -330,12 +332,13 @@ test("deriveTree - independent updates to different directories", async (t) => { }); test("deriveTree - preserves tree statistics correctly", (t) => { + const registry = new TreeRegistry(); const resources = [ {path: "dir1/a.js", integrity: "hash-a"}, {path: "dir1/b.js", integrity: "hash-b"} ]; - const tree1 = new HashTree(resources); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([ {path: "dir2/c.js", integrity: "hash-c"}, {path: "dir2/d.js", integrity: "hash-d"} @@ -350,11 +353,12 @@ test("deriveTree - preserves tree statistics correctly", (t) => { }); test("deriveTree - empty derivation creates exact copy with shared nodes", (t) => { + const registry = new TreeRegistry(); const resources = [ {path: "file.js", integrity: "hash1"} ]; - const tree1 = new HashTree(resources); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([]); // Should have same structure @@ -377,7 +381,7 @@ test("deriveTree - complex shared structure", async (t) => { {path: "shared/file3.js", integrity: "hash3"} ]; - const tree1 = new HashTree(resources, {registry}); + const tree1 = new SharedHashTree(resources, registry); const tree2 = tree1.deriveTree([ {path: "unique/file4.js", integrity: "hash4"} ]); @@ -404,7 +408,7 @@ test("deriveTree - complex shared structure", async (t) => { test("upsertResources - with registry schedules operations", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); const result = await tree.upsertResources([ createMockResource("b.js", "hash-b", Date.now(), 1024, 1) @@ -415,7 +419,7 @@ test("upsertResources - with registry schedules operations", async (t) => { test("upsertResources - with registry and flush", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); const originalHash = tree.getRootHash(); await tree.upsertResources([ @@ -436,7 +440,7 @@ test("upsertResources - with registry and flush", async (t) => { test("upsertResources - with derived trees", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([{path: "shared/a.js", integrity: "hash-a"}], {registry}); + const tree1 = new SharedHashTree([{path: "shared/a.js", integrity: "hash-a"}], registry); const tree2 = tree1.deriveTree([{path: "unique/b.js", integrity: "hash-b"}]); await tree1.upsertResources([ @@ -457,10 +461,10 @@ test("upsertResources - with derived trees", async (t) => { test("removeResources - with registry schedules operations", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([ + const tree = new SharedHashTree([ {path: "a.js", integrity: "hash-a"}, {path: "b.js", integrity: "hash-b"} - ], {registry}); + ], registry); const result = await tree.removeResources(["b.js"]); @@ -469,11 +473,11 @@ test("removeResources - with registry schedules operations", async (t) => { test("removeResources - with registry and flush", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([ + const tree = new SharedHashTree([ {path: "a.js", integrity: "hash-a"}, {path: "b.js", integrity: "hash-b"}, {path: "c.js", integrity: "hash-c"} - ], {registry}); + ], registry); const originalHash = tree.getRootHash(); await tree.removeResources(["b.js", "c.js"]); @@ -492,10 +496,10 @@ test("removeResources - with registry and flush", async (t) => { test("removeResources - with derived trees propagates removal", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([ + const tree1 = new SharedHashTree([ {path: "shared/a.js", integrity: "hash-a"}, {path: "shared/b.js", integrity: "hash-b"} - ], {registry}); + ], registry); const tree2 = tree1.deriveTree([{path: "unique/c.js", integrity: "hash-c"}]); // Verify both trees share the resources @@ -518,10 +522,10 @@ test("removeResources - with derived trees propagates removal", async (t) => { test("removeResources - with registry cleans up empty directories", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([ + const tree = new SharedHashTree([ {path: "dir1/dir2/only.js", integrity: "hash-only"}, {path: "dir1/other.js", integrity: "hash-other"} - ], {registry}); + ], registry); // Verify structure before removal t.truthy(tree.hasPath("dir1/dir2/only.js"), "Should have dir1/dir2/only.js"); @@ -545,10 +549,10 @@ test("removeResources - with registry cleans up empty directories", async (t) => test("removeResources - with registry cleans up deeply nested empty directories", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([ + const tree = new SharedHashTree([ {path: "a/b/c/d/e/deep.js", integrity: "hash-deep"}, {path: "a/sibling.js", integrity: "hash-sibling"} - ], {registry}); + ], registry); // Verify structure before removal t.truthy(tree.hasPath("a/b/c/d/e/deep.js"), "Should have deeply nested file"); @@ -573,10 +577,10 @@ test("removeResources - with registry cleans up deeply nested empty directories" test("removeResources - with derived trees cleans up empty directories in both trees", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([ + const tree1 = new SharedHashTree([ {path: "shared/dir/only.js", integrity: "hash-only"}, {path: "shared/other.js", integrity: "hash-other"} - ], {registry}); + ], registry); const tree2 = tree1.deriveTree([{path: "unique/file.js", integrity: "hash-unique"}]); // Verify both trees share the directory structure @@ -604,11 +608,11 @@ test("removeResources - with derived trees cleans up empty directories in both t test("removeResources - multiple removals with registry clean up shared empty directories", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([ + const tree = new SharedHashTree([ {path: "dir1/sub1/file1.js", integrity: "hash1"}, {path: "dir1/sub2/file2.js", integrity: "hash2"}, {path: "dir2/file3.js", integrity: "hash3"} - ], {registry}); + ], registry); // Remove both files from dir1 (making both sub1 and sub2 empty) await tree.removeResources(["dir1/sub1/file1.js", "dir1/sub2/file2.js"]); @@ -632,10 +636,10 @@ test("removeResources - multiple removals with registry clean up shared empty di test("upsertResources and removeResources - combined operations", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([ + const tree = new SharedHashTree([ {path: "a.js", integrity: "hash-a"}, {path: "b.js", integrity: "hash-b"} - ], {registry}); + ], registry); const originalHash = tree.getRootHash(); // Schedule both operations @@ -657,7 +661,7 @@ test("upsertResources and removeResources - combined operations", async (t) => { test("upsertResources and removeResources - conflicting operations on same path", async (t) => { const registry = new TreeRegistry(); - const tree = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); + const tree = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); // Schedule removal then upsert (upsert should win) await tree.removeResources(["a.js"]); @@ -679,8 +683,8 @@ test("upsertResources and removeResources - conflicting operations on same path" test("TreeRegistry - flush returns per-tree statistics", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); - const tree2 = new HashTree([{path: "b.js", integrity: "hash-b"}], {registry}); + const tree1 = new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + const tree2 = new SharedHashTree([{path: "b.js", integrity: "hash-b"}], registry); // Update tree1 resource registry.scheduleUpsert(createMockResource("a.js", "new-hash-a", Date.now(), 1024, 1)); @@ -724,10 +728,10 @@ test("TreeRegistry - flush returns per-tree statistics", async (t) => { test("TreeRegistry - per-tree statistics with shared nodes", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([ + const tree1 = new SharedHashTree([ {path: "shared/a.js", integrity: "hash-a"}, {path: "shared/b.js", integrity: "hash-b"} - ], {registry}); + ], registry); const tree2 = tree1.deriveTree([{path: "unique/c.js", integrity: "hash-c"}]); // Verify trees share the "shared" directory @@ -762,11 +766,11 @@ test("TreeRegistry - per-tree statistics with shared nodes", async (t) => { test("TreeRegistry - per-tree statistics with mixed operations", async (t) => { const registry = new TreeRegistry(); - const tree1 = new HashTree([ + const tree1 = new SharedHashTree([ {path: "a.js", integrity: "hash-a"}, {path: "b.js", integrity: "hash-b"}, {path: "c.js", integrity: "hash-c"} - ], {registry}); + ], registry); const tree2 = tree1.deriveTree([{path: "d.js", integrity: "hash-d"}]); // Update a.js (affects both trees - shared) @@ -808,20 +812,20 @@ test("TreeRegistry - per-tree statistics with mixed operations", async (t) => { test("TreeRegistry - per-tree statistics with no changes", async (t) => { const registry = new TreeRegistry(); const timestamp = Date.now(); - const tree1 = new HashTree([{ + const tree1 = new SharedHashTree([{ path: "a.js", integrity: "hash-a", lastModified: timestamp, size: 1024, inode: 100 - }], {registry}); - const tree2 = new HashTree([{ + }], registry); + const tree2 = new SharedHashTree([{ path: "b.js", integrity: "hash-b", lastModified: timestamp, size: 2048, inode: 200 - }], {registry}); + }], registry); // Schedule updates with unchanged metadata // Note: These will add missing resources to the other tree @@ -861,8 +865,8 @@ test("TreeRegistry - per-tree statistics with no changes", async (t) => { test("TreeRegistry - empty flush returns empty treeStats", async (t) => { const registry = new TreeRegistry(); - new HashTree([{path: "a.js", integrity: "hash-a"}], {registry}); - new HashTree([{path: "b.js", integrity: "hash-b"}], {registry}); + new SharedHashTree([{path: "a.js", integrity: "hash-a"}], registry); + new SharedHashTree([{path: "b.js", integrity: "hash-b"}], registry); // Flush without scheduling any operations const result = await registry.flush(); @@ -878,10 +882,10 @@ test("TreeRegistry - derived tree reflects base tree resource changes in statist const registry = new TreeRegistry(); // Create base tree with some resources - const baseTree = new HashTree([ + const baseTree = new SharedHashTree([ {path: "shared/resource1.js", integrity: "hash1"}, {path: "shared/resource2.js", integrity: "hash2"} - ], {registry}); + ], registry); // Derive a new tree from base tree (shares same registry) // Note: deriveTree doesn't schedule the new resources, it adds them directly to the derived tree From 9606b2c41b35974f2dd5d33e0327470637e2a87d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 15 Jan 2026 18:06:57 +0100 Subject: [PATCH 090/223] refactor(project): Re-implement differential task build --- packages/project/lib/build/TaskRunner.js | 9 +- .../project/lib/build/cache/BuildTaskCache.js | 68 +++--- .../project/lib/build/cache/CacheManager.js | 2 +- .../lib/build/cache/ProjectBuildCache.js | 226 ++++++++++-------- .../lib/build/cache/ResourceRequestManager.js | 174 +++++++------- .../lib/build/helpers/ProjectBuildContext.js | 3 +- 6 files changed, 259 insertions(+), 223 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 49238a1d1ec..a93ed299e93 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -195,7 +195,7 @@ class TaskRunner { this._log.skipTask(taskName); return; } - const usingCache = supportsDifferentialUpdates && cacheInfo; + const usingCache = !!(supportsDifferentialUpdates && cacheInfo); const workspace = createMonitor(this._project.getWorkspace()); const params = { workspace, @@ -209,9 +209,9 @@ class TaskRunner { params.dependencies = dependencies; } if (usingCache) { - params.changedProjectResourcePaths = Array.from(cacheInfo.changedProjectResourcePaths); + params.changedProjectResourcePaths = cacheInfo.changedProjectResourcePaths; if (requiresDependencies) { - params.changedDependencyResourcePaths = Array.from(cacheInfo.changedDependencyResourcePaths); + params.changedDependencyResourcePaths = cacheInfo.changedDependencyResourcePaths; } } if (!taskFunction) { @@ -229,7 +229,8 @@ class TaskRunner { await this._buildCache.recordTaskResult(taskName, workspace.getResourceRequests(), dependencies?.getResourceRequests(), - usingCache ? cacheInfo : undefined); + usingCache ? cacheInfo : undefined, + supportsDifferentialUpdates); }; } this._tasks[taskName] = { diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 9d82c78ff50..f9b8820800a 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -26,8 +26,9 @@ const log = getLogger("build:cache:BuildTaskCache"); * to reuse existing resource indices, optimizing both memory and computation. */ export default class BuildTaskCache { - #taskName; #projectName; + #taskName; + #supportsDifferentialUpdates; #projectRequestManager; #dependencyRequestManager; @@ -35,24 +36,32 @@ export default class BuildTaskCache { /** * Creates a new BuildTaskCache instance * - * @param {string} taskName - Name of the task this cache manages * @param {string} projectName - Name of the project this task belongs to - * @param {object} [cachedTaskMetadata] + * @param {string} taskName - Name of the task this cache manages + * @param {boolean} supportsDifferentialUpdates + * @param {ResourceRequestManager} [projectRequestManager] + * @param {ResourceRequestManager} [dependencyRequestManager] */ - constructor(taskName, projectName, cachedTaskMetadata) { - this.#taskName = taskName; + constructor(projectName, taskName, supportsDifferentialUpdates, projectRequestManager, dependencyRequestManager) { this.#projectName = projectName; + this.#taskName = taskName; + this.#supportsDifferentialUpdates = supportsDifferentialUpdates; + log.verbose(`Initializing BuildTaskCache for task "${taskName}" of project "${this.#projectName}" ` + + `(supportsDifferentialUpdates=${supportsDifferentialUpdates})`); + + this.#projectRequestManager = projectRequestManager ?? + new ResourceRequestManager(projectName, taskName, supportsDifferentialUpdates); + this.#dependencyRequestManager = dependencyRequestManager ?? + new ResourceRequestManager(projectName, taskName, supportsDifferentialUpdates); + } - if (cachedTaskMetadata) { - this.#projectRequestManager = ResourceRequestManager.fromCache(taskName, projectName, - cachedTaskMetadata.projectRequests); - this.#dependencyRequestManager = ResourceRequestManager.fromCache(taskName, projectName, - cachedTaskMetadata.dependencyRequests); - } else { - // No cache reader provided, start with empty graph - this.#projectRequestManager = new ResourceRequestManager(taskName, projectName); - this.#dependencyRequestManager = new ResourceRequestManager(taskName, projectName); - } + static fromCache(projectName, taskName, supportsDifferentialUpdates, projectRequests, dependencyRequests) { + const projectRequestManager = ResourceRequestManager.fromCache(projectName, taskName, + supportsDifferentialUpdates, projectRequests); + const dependencyRequestManager = ResourceRequestManager.fromCache(projectName, taskName, + supportsDifferentialUpdates, dependencyRequests); + return new BuildTaskCache(projectName, taskName, supportsDifferentialUpdates, + projectRequestManager, dependencyRequestManager); } // ===== METADATA ACCESS ===== @@ -66,6 +75,10 @@ export default class BuildTaskCache { return this.#taskName; } + getSupportsDifferentialUpdates() { + return this.#supportsDifferentialUpdates; + } + hasNewOrModifiedCacheEntries() { return this.#projectRequestManager.hasNewOrModifiedCacheEntries() || this.#dependencyRequestManager.hasNewOrModifiedCacheEntries(); @@ -105,7 +118,7 @@ export default class BuildTaskCache { * a unique combination of resources, belonging to the current project, that were accessed * during task execution. This can be used to form a cache keys for restoring cached task results. * - * @returns {Promise} Array of signature strings + * @returns {string[]} Array of signature strings * @throws {Error} If resource index is missing for any request set */ getProjectIndexSignatures() { @@ -119,13 +132,21 @@ export default class BuildTaskCache { * a unique combination of resources, belonging to all dependencies of the current project, that were accessed * during task execution. This can be used to form a cache keys for restoring cached task results. * - * @returns {Promise} Array of signature strings + * @returns {string[]} Array of signature strings * @throws {Error} If resource index is missing for any request set */ getDependencyIndexSignatures() { return this.#dependencyRequestManager.getIndexSignatures(); } + getProjectIndexDeltas() { + return this.#projectRequestManager.getDeltas(); + } + + getDependencyIndexDeltas() { + return this.#dependencyRequestManager.getDeltas(); + } + /** * Calculates a signature for the task based on accessed resources * @@ -159,27 +180,20 @@ export default class BuildTaskCache { this.#projectRequestManager.addAffiliatedRequestSet(projectReqSetId, depReqSetId); dependencyReqSignature = depReqSignature; } else { - dependencyReqSignature = "X"; // No dependencies accessed + dependencyReqSignature = this.#dependencyRequestManager.recordNoRequests(); } return [projectReqSignature, dependencyReqSignature]; } - findDelta() { - // TODO: Implement - } - /** * Serializes the task cache to a plain object for persistence * * Exports the resource request graph in a format suitable for JSON serialization. * The serialized data can be passed to the constructor to restore the cache state. * - * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph + * @returns {object[]} Serialized cache metadata containing the request set graphs */ toCacheObjects() { - return { - projectRequests: this.#projectRequestManager.toCacheObject(), - dependencyRequests: this.#dependencyRequestManager.toCacheObject(), - }; + return [this.#projectRequestManager.toCacheObject(), this.#dependencyRequestManager.toCacheObject()]; } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 28a91425305..d8088e3f4ee 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -20,7 +20,7 @@ const chacheManagerInstances = new Map(); const CACACHE_OPTIONS = {algorithms: ["sha256"]}; // Cache version for compatibility management -const CACHE_VERSION = "v0"; +const CACHE_VERSION = "v0_0"; /** * Manages persistence for the build cache using file-based storage and cacache diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 469c748ac6b..e7605309bd7 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -94,7 +94,8 @@ export default class ProjectBuildCache { * * @param {@ui5/fs/AbstractReader} dependencyReader - Reader for dependency resources * @param {boolean} [forceDependencyUpdate=false] - * @returns {Promise} True if cache is fresh and can be fully utilized, false otherwise + * @returns {Promise} Undefined if no cache has been found. Otherwise a list of changed + * resources */ async prepareProjectBuildAndValidateCache(dependencyReader, forceDependencyUpdate = false) { this.#currentProjectReader = this.#project.getReader(); @@ -271,27 +272,15 @@ export default class ProjectBuildCache { await taskCache.updateProjectIndices(this.#currentProjectReader, this.#writtenResultResourcePaths); } - // let deltaInfo; - // if (this.#invalidatedTasks.has(taskName)) { - // const invalidationInfo = - // this.#invalidatedTasks.get(taskName); - // log.verbose(`Task cache for task ${taskName} has been invalidated, updating indices ` + - // `with ${invalidationInfo.changedProjectResourcePaths.size} changed project resource paths and ` + - // `${invalidationInfo.changedDependencyResourcePaths.size} changed dependency resource paths...`); - - - // // deltaInfo = await taskCache.updateIndices( - // // invalidationInfo.changedProjectResourcePaths, - // // invalidationInfo.changedDependencyResourcePaths, - // // this.#currentProjectReader, this.#currentDependencyReader); - // } // else: Index will be created upon task completion - + // TODO: Implement: // After index update, try to find cached stages for the new signatures - // let stageSignatures = taskCache.getAffiliatedSignaturePairs(); // TODO: Implement + // let stageSignatures = taskCache.getAffiliatedSignaturePairs(); + const projectSignatures = taskCache.getProjectIndexSignatures(); + const dependencySignatures = taskCache.getDependencyIndexSignatures(); const stageSignatures = combineTwoArraysFast( - taskCache.getProjectIndexSignatures(), - taskCache.getDependencyIndexSignatures() + projectSignatures, + dependencySignatures, ).map((signaturePair) => { return createStageSignature(...signaturePair); }); @@ -303,14 +292,9 @@ export default class ProjectBuildCache { // Store dependency signature for later use in result stage signature calculation this.#currentDependencySignatures.set(taskName, stageCache.signature.split("-")[1]); - // Task can be skipped, use cached stage as project reader - // if (this.#invalidatedTasks.has(taskName)) { - // this.#invalidatedTasks.delete(taskName); - // } - - if (!stageChanged) { - // Invalidate following tasks - // this.#invalidateFollowingTasks(taskName, Array.from(stageCache.writtenResourcePaths)); + // Cached stage might differ from the previous one + // Add all resources written by the cached stage to the set of written/potentially changed resources + if (stageChanged) { for (const resourcePath of stageCache.writtenResourcePaths) { if (!this.#writtenResultResourcePaths.includes(resourcePath)) { this.#writtenResultResourcePaths.push(resourcePath); @@ -320,24 +304,65 @@ export default class ProjectBuildCache { return true; // No need to execute the task } else { log.verbose(`No cached stage found for task ${taskName} in project ${this.#project.getName()}`); - // TODO: Re-implement - // const deltaInfo = taskCache.findDelta(); + // TODO: Optimize this crazy thing + const projectDeltas = taskCache.getProjectIndexDeltas(); + const depDeltas = taskCache.getDependencyIndexDeltas(); + + // Combine deltas of project stages with cached dependency signatures + const projDeltaSignatures = combineTwoArraysFast( + Array.from(projectDeltas.keys()), + dependencySignatures, + ).map((signaturePair) => { + return createStageSignature(...signaturePair); + }); + // Combine deltas of dependency stages with cached project signatures + const depDeltaSignatures = combineTwoArraysFast( + projectSignatures, + Array.from(projectDeltas.keys()), + ).map((signaturePair) => { + return createStageSignature(...signaturePair); + }); + // Combine deltas of both project and dependency stages + const deltaDeltaSignatures = combineTwoArraysFast( + Array.from(projectDeltas.keys()), + Array.from(depDeltas.keys()), + ).map((signaturePair) => { + return createStageSignature(...signaturePair); + }); + const deltaSignatures = [...projDeltaSignatures, ...depDeltaSignatures, ...deltaDeltaSignatures]; + const deltaStageCache = await this.#findStageCache(stageName, deltaSignatures); + if (deltaStageCache) { + // Store dependency signature for later use in result stage signature calculation + const [foundProjectSig, foundDepSig] = deltaStageCache.signature.split("-"); + this.#currentDependencySignatures.set(taskName, foundDepSig); + const projectDeltaInfo = projectDeltas.get(foundProjectSig); + const dependencyDeltaInfo = depDeltas.get(foundDepSig); + + const newSignature = createStageSignature( + projectDeltaInfo?.newSignature ?? foundProjectSig, + dependencyDeltaInfo?.newSignature ?? foundDepSig); + + // Using cached stage which might differ from the previous one + // Add all resources written by the cached stage to the set of written/potentially changed resources + for (const resourcePath of deltaStageCache.writtenResourcePaths) { + if (!this.#writtenResultResourcePaths.includes(resourcePath)) { + this.#writtenResultResourcePaths.push(resourcePath); + } + } - // const deltaStageCache = await this.#findStageCache(stageName, [deltaInfo.originalSignature]); - // if (deltaStageCache) { - // log.verbose( - // `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + - // `with original signature ${deltaInfo.originalSignature} (now ${deltaInfo.newSignature}) ` + - // `and ${deltaInfo.changedProjectResourcePaths.size} changed project resource paths and ` + - // `${deltaInfo.changedDependencyResourcePaths.size} changed dependency resource paths.`); - - // return { - // previousStageCache: deltaStageCache, - // newSignature: deltaInfo.newSignature, - // changedProjectResourcePaths: deltaInfo.changedProjectResourcePaths, - // changedDependencyResourcePaths: deltaInfo.changedDependencyResourcePaths - // }; - // } + log.verbose( + `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + + `with original signature ${deltaStageCache.signature} (now ${newSignature}) ` + + `and ${projectDeltaInfo?.changedPaths.length ?? "unknown"} changed project resource paths and ` + + `${dependencyDeltaInfo?.changedPaths.length ?? "unknown"} changed dependency resource paths.`); + + return { + previousStageCache: deltaStageCache, + newSignature: newSignature, + changedProjectResourcePaths: projectDeltaInfo?.changedPaths ?? [], + changedDependencyResourcePaths: dependencyDeltaInfo?.changedPaths ?? [] + }; + } } return false; // Task needs to be executed } @@ -354,35 +379,36 @@ export default class ProjectBuildCache { * @returns {Promise} Cached stage entry or null if not found */ async #findStageCache(stageName, stageSignatures) { + if (!stageSignatures.length) { + return; + } // Check cache exists and ensure it's still valid before using it log.verbose(`Looking for cached stage for task ${stageName} in project ${this.#project.getName()} ` + `with ${stageSignatures.length} possible signatures:\n - ${stageSignatures.join("\n - ")}`); - if (stageSignatures.length) { - for (const stageSignature of stageSignatures) { - const stageCache = this.#stageCache.getCacheForSignature(stageName, stageSignature); - if (stageCache) { - return stageCache; - } + for (const stageSignature of stageSignatures) { + const stageCache = this.#stageCache.getCacheForSignature(stageName, stageSignature); + if (stageCache) { + return stageCache; } - // TODO: If list of signatures is longer than N, - // retrieve all available signatures from cache manager first. - // Later maybe add a bloom filter for even larger sets - const stageCache = await firstTruthy(stageSignatures.map(async (stageSignature) => { - const stageMetadata = await this.#cacheManager.readStageCache( - this.#project.getId(), this.#buildSignature, stageName, stageSignature); - if (stageMetadata) { - log.verbose(`Found cached stage with signature ${stageSignature}`); - const reader = this.#createReaderForStageCache( - stageName, stageSignature, stageMetadata.resourceMetadata); - return { - signature: stageSignature, - stage: reader, - writtenResourcePaths: Object.keys(stageMetadata.resourceMetadata), - }; - } - })); - return stageCache; } + // TODO: If list of signatures is longer than N, + // retrieve all available signatures from cache manager first. + // Later maybe add a bloom filter for even larger sets + const stageCache = await firstTruthy(stageSignatures.map(async (stageSignature) => { + const stageMetadata = await this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, stageName, stageSignature); + if (stageMetadata) { + log.verbose(`Found cached stage with signature ${stageSignature}`); + const reader = this.#createReaderForStageCache( + stageName, stageSignature, stageMetadata.resourceMetadata); + return { + signature: stageSignature, + stage: reader, + writtenResourcePaths: Object.keys(stageMetadata.resourceMetadata), + }; + } + })); + return stageCache; } /** @@ -400,12 +426,16 @@ export default class ProjectBuildCache { * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests|undefined} dependencyResourceRequests * Resource requests for dependency resources * @param {object} cacheInfo + * @param {boolean} supportsDifferentialUpdates - Whether the task supports differential updates * @returns {Promise} */ - async recordTaskResult(taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo) { + async recordTaskResult( + taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo, supportsDifferentialUpdates + ) { if (!this.#taskCache.has(taskName)) { // Initialize task cache - this.#taskCache.set(taskName, new BuildTaskCache(taskName, this.#project.getName())); + this.#taskCache.set(taskName, + new BuildTaskCache(this.#project.getName(), taskName, supportsDifferentialUpdates)); } log.verbose(`Recording results of task ${taskName} in project ${this.#project.getName()}...`); const taskCache = this.#taskCache.get(taskName); @@ -457,11 +487,6 @@ export default class ProjectBuildCache { this.#getStageNameForTask(taskName), stageSignature, this.#project.getStage(), writtenResourcePaths); - // // Task has been successfully executed, remove from invalidated tasks - // if (this.#invalidatedTasks.has(taskName)) { - // this.#invalidatedTasks.delete(taskName); - // } - // Update task cache with new metadata log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); @@ -590,24 +615,24 @@ export default class ProjectBuildCache { await ResourceIndex.fromCacheWithDelta(indexCache, resources, Date.now()); // Import task caches - const buildTaskCaches = await Promise.all(indexCache.taskList.map(async (taskName) => { - const projectRequests = await this.#cacheManager.readTaskMetadata( - this.#project.getId(), this.#buildSignature, `${taskName}-pr`); - if (!projectRequests) { - throw new Error(`Failed to load project request cache for task ` + - `${taskName} in project ${this.#project.getName()}`); - } - const dependencyRequests = await this.#cacheManager.readTaskMetadata( - this.#project.getId(), this.#buildSignature, `${taskName}-dr`); - if (!dependencyRequests) { - throw new Error(`Failed to load dependency request cache for task ` + - `${taskName} in project ${this.#project.getName()}`); - } - return new BuildTaskCache(taskName, this.#project.getName(), { - projectRequests, - dependencyRequests, - }); - })); + const buildTaskCaches = await Promise.all( + indexCache.tasks.map(async ([taskName, supportsDifferentialUpdates]) => { + const projectRequests = await this.#cacheManager.readTaskMetadata( + this.#project.getId(), this.#buildSignature, `${taskName}-pr`); + if (!projectRequests) { + throw new Error(`Failed to load project request cache for task ` + + `${taskName} in project ${this.#project.getName()}`); + } + const dependencyRequests = await this.#cacheManager.readTaskMetadata( + this.#project.getId(), this.#buildSignature, `${taskName}-dr`); + if (!dependencyRequests) { + throw new Error(`Failed to load dependency request cache for task ` + + `${taskName} in project ${this.#project.getName()}`); + } + return BuildTaskCache.fromCache(this.#project.getName(), taskName, !!supportsDifferentialUpdates, + projectRequests, dependencyRequests); + }) + ); // Ensure taskCache is filled in the order of task execution for (const buildTaskCache of buildTaskCaches) { this.#taskCache.set(buildTaskCache.getTaskName(), buildTaskCache); @@ -728,9 +753,10 @@ export default class ProjectBuildCache { const deltaReader = this.#project.getReader({excludeSourceReader: true}); const resources = await deltaReader.byGlob("/**/*"); const resourceMetadata = Object.create(null); - log.verbose(`Project ${this.#project.getName()} result stage signature is: ${stageSignature}`); - log.verbose(`Cache state: ${this.#cacheState}`); - log.verbose(`Storing result stage cache with ${resources.length} resources`); + log.verbose(`Writing result cache for project ${this.#project.getName()}:\n` + + `- Result stage signature is: ${stageSignature}\n` + + `- Cache state: ${this.#cacheState}\n` + + `- Storing ${resources.length} resources`); await Promise.all(resources.map(async (res) => { // Store resource content in cacache via CacheManager @@ -789,7 +815,7 @@ export default class ProjectBuildCache { // Store task caches for (const [taskName, taskCache] of this.#taskCache) { if (taskCache.hasNewOrModifiedCacheEntries()) { - const {projectRequests, dependencyRequests} = taskCache.toCacheObjects(); + const [projectRequests, dependencyRequests] = taskCache.toCacheObjects(); log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + `with build signature ${this.#buildSignature}`); const writes = []; @@ -814,9 +840,13 @@ export default class ProjectBuildCache { log.verbose(`Storing resource index cache for project ${this.#project.getName()} ` + `with build signature ${this.#buildSignature}`); const sourceIndexObject = this.#sourceIndex.toCacheObject(); + const tasks = []; + for (const [taskName, taskCache] of this.#taskCache) { + tasks.push([taskName, taskCache.getSupportsDifferentialUpdates() ? 1 : 0]); + } await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "source", { ...sourceIndexObject, - taskList: Array.from(this.#taskCache.keys()), + tasks, }); } diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index d2d55cbeb3a..4ae8880ce0a 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -11,14 +11,17 @@ class ResourceRequestManager { #requestGraph; #treeRegistries = []; - #treeDiffs = new Map(); + #treeUpdateDeltas = new Map(); #hasNewOrModifiedCacheEntries; - #useDifferentialUpdate = true; + #useDifferentialUpdate; + #unusedAtLeastOnce; - constructor(taskName, projectName, requestGraph) { - this.#taskName = taskName; + constructor(projectName, taskName, useDifferentialUpdate, requestGraph, unusedAtLeastOnce = false) { this.#projectName = projectName; + this.#taskName = taskName; + this.#useDifferentialUpdate = useDifferentialUpdate; + this.#unusedAtLeastOnce = unusedAtLeastOnce; if (requestGraph) { this.#requestGraph = requestGraph; this.#hasNewOrModifiedCacheEntries = false; // Using cache @@ -28,9 +31,12 @@ class ResourceRequestManager { } } - static fromCache(taskName, projectName, {requestSetGraph, rootIndices, deltaIndices}) { + static fromCache(projectName, taskName, useDifferentialUpdate, { + requestSetGraph, rootIndices, deltaIndices, unusedAtLeastOnce + }) { const requestGraph = ResourceRequestGraph.fromCacheObject(requestSetGraph); - const resourceRequestManager = new ResourceRequestManager(taskName, projectName, requestGraph); + const resourceRequestManager = new ResourceRequestManager( + projectName, taskName, useDifferentialUpdate, requestGraph, unusedAtLeastOnce); const registries = new Map(); // Restore root resource indices for (const {nodeId, resourceIndex: serializedIndex} of rootIndices) { @@ -71,9 +77,6 @@ class ResourceRequestManager { */ getIndexSignatures() { const requestSetIds = this.#requestGraph.getAllNodeIds(); - if (requestSetIds.length === 0) { - return ["X"]; // No requests recorded, return static signature - } const signatures = requestSetIds.map((requestSetId) => { const {resourceIndex} = this.#requestGraph.getMetadata(requestSetId); if (!resourceIndex) { @@ -81,6 +84,9 @@ class ResourceRequestManager { } return resourceIndex.getSignature(); }); + if (this.#unusedAtLeastOnce) { + signatures.push("X"); // Signature for when no requests were made + } return signatures; } @@ -155,6 +161,9 @@ class ResourceRequestManager { matchingRequestSetIds.push(nodeId); } } + if (!matchingRequestSetIds.length) { + return false; // No relevant changes for any request set + } const resourceCache = new Map(); // Update matching resource indices @@ -245,19 +254,15 @@ class ResourceRequestManager { */ async #flushTreeChangesWithDiffTracking() { const requestSetIds = this.#requestGraph.getAllNodeIds(); + const previousTreeSignatures = new Map(); // Record current signatures and create mapping between trees and request sets requestSetIds.map((requestSetId) => { const {resourceIndex} = this.#requestGraph.getMetadata(requestSetId); if (!resourceIndex) { throw new Error(`Resource index missing for request set ID ${requestSetId}`); } - // Store original signatures for all trees that are not yet tracked - if (!this.#treeDiffs.has(resourceIndex.getTree())) { - this.#treeDiffs.set(resourceIndex.getTree(), { - requestSetId, - signature: resourceIndex.getSignature(), - }); - } + // Remember the original signature + previousTreeSignatures.set(resourceIndex.getTree(), [requestSetId, resourceIndex.getSignature()]); }); const results = await this.#flushTreeChanges(); let hasChanges = false; @@ -265,68 +270,13 @@ class ResourceRequestManager { if (res.added.length || res.updated.length || res.unchanged.length || res.removed.length) { hasChanges = true; } - for (const [tree, stats] of res.treeStats) { - this.#addStatsToTreeDiff(this.#treeDiffs.get(tree), stats); + for (const [tree, diff] of res.treeStats) { + const [requestSetId, originalSignature] = previousTreeSignatures.get(tree); + const newSignature = tree.getRootHash(); + this.#addDeltaEntry(requestSetId, originalSignature, newSignature, diff); } } return hasChanges; - - // let greatestNumberOfChanges = 0; - // let relevantTree; - // let relevantStats; - // let hasChanges = false; - // const results = await this.#flushTreeChanges(); - - // // Based on the returned stats, find the tree with the greatest difference - // // If none of the updated trees lead to a valid cache, this tree can be used to execute a differential - // // build (assuming there's a cache for its previous signature) - // for (const res of results) { - // if (res.added.length || res.updated.length || res.unchanged.length || res.removed.length) { - // hasChanges = true; - // } - // for (const [tree, stats] of res.treeStats) { - // if (stats.removed.length > 0) { - // // If the update process removed resources from that tree, this means that using it in a - // // differential build might lead to stale removed resources - // return; // TODO: continue; instead? - // } - // const numberOfChanges = stats.added.length + stats.updated.length; - // if (numberOfChanges > greatestNumberOfChanges) { - // greatestNumberOfChanges = numberOfChanges; - // relevantTree = tree; - // relevantStats = stats; - // } - // } - // } - // if (hasChanges) { - // this.#hasNewOrModifiedCacheEntries = true; - // } - - // if (!relevantTree) { - // return hasChanges; - // } - - // // Update signatures for affected request sets - // const {requestSetId, signature: originalSignature} = trees.get(relevantTree); - // const newSignature = relevantTree.getRootHash(); - // log.verbose(`Task '${this.#taskName}' of project '${this.#projectName}' ` + - // `updated resource index for request set ID ${requestSetId} ` + - // `from signature ${originalSignature} ` + - // `to ${newSignature}`); - - // const changedPaths = new Set(); - // for (const path of relevantStats.added) { - // changedPaths.add(path); - // } - // for (const path of relevantStats.updated) { - // changedPaths.add(path); - // } - - // return { - // originalSignature, - // newSignature, - // changedPaths, - // }; } /** @@ -341,27 +291,61 @@ class ResourceRequestManager { return await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); } - #addStatsToTreeDiff(treeDiff, stats) { - if (!treeDiff.stats) { - treeDiff.stats = { - added: new Set(), - updated: new Set(), - unchanged: new Set(), - removed: new Set(), - }; + #addDeltaEntry(requestSetId, originalSignature, newSignature, diff) { + if (!this.#treeUpdateDeltas.has(requestSetId)) { + this.#treeUpdateDeltas.set(requestSetId, { + originalSignature, + newSignature, + diff + }); + return; + } + const entry = this.#treeUpdateDeltas.get(requestSetId); + + entry.previousSignatures ??= []; + entry.previousSignatures.push(entry.originalSignature); + entry.originalSignature = originalSignature; + entry.newSignature = newSignature; + + const {added, updated, unchanged, removed} = entry.diff; + for (const resourcePath of diff.added) { + if (!added.includes(resourcePath)) { + added.push(resourcePath); + } } - for (const path of stats.added) { - treeDiff.stats.added.add(path); + for (const resourcePath of diff.updated) { + if (!updated.includes(resourcePath)) { + updated.push(resourcePath); + } } - for (const path of stats.updated) { - treeDiff.stats.updated.add(path); + for (const resourcePath of diff.unchanged) { + if (!unchanged.includes(resourcePath)) { + unchanged.push(resourcePath); + } } - for (const path of stats.unchanged) { - treeDiff.stats.unchanged.add(path); + for (const resourcePath of diff.removed) { + if (!removed.includes(resourcePath)) { + removed.push(resourcePath); + } } - for (const path of stats.removed) { - treeDiff.stats.removed.add(path); + } + + getDeltas() { + const deltas = new Map(); + for (const {originalSignature, newSignature, diff} of this.#treeUpdateDeltas.values()) { + let changedPaths; + if (diff) { + const {added, updated, removed} = diff; + changedPaths = Array.from(new Set([...added, ...updated, ...removed])); + } else { + changedPaths = []; + } + deltas.set(originalSignature, { + newSignature, + changedPaths, + }); } + return deltas; } /** @@ -381,6 +365,11 @@ class ResourceRequestManager { return await this.#addRequestSet(projectRequests, reader); } + recordNoRequests() { + this.#unusedAtLeastOnce = true; + return "X"; // Signature for when no requests were made + } + async #addRequestSet(requests, reader) { // Try to find an existing request set that we can reuse let setId = this.#requestGraph.findExactMatch(requests); @@ -413,7 +402,7 @@ class ResourceRequestManager { } else { const resourcesRead = await this.#getResourcesForRequests(requests, reader); - resourceIndex = await ResourceIndex.create(resourcesRead, Date.now(), this.#newTreeRegistry()); + resourceIndex = await ResourceIndex.createShared(resourcesRead, Date.now(), this.#newTreeRegistry()); } metadata.resourceIndex = resourceIndex; } @@ -526,6 +515,7 @@ class ResourceRequestManager { requestSetGraph: this.#requestGraph.toCacheObject(), rootIndices, deltaIndices, + unusedAtLeastOnce: this.#unusedAtLeastOnce, }; } } diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 8372b831c49..5cc25f8e0c9 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -180,7 +180,8 @@ class ProjectBuildContext { * Prepares the project build by updating, and then validating the build cache as needed * * @param {boolean} initialBuild - * @returns {Promise} True if project cache is fresh and can be used, false otherwise + * @returns {Promise} Undefined if no cache has been found. Otherwise a list of changed + * resources */ async prepareProjectBuildAndValidateCache(initialBuild) { // if (this.getBuildCache().hasCache() && this.getBuildCache().requiresDependencyIndexInitialization()) { From c279a7d00d86c36e2d0797f065197e89f1b5cce8 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 16 Jan 2026 15:07:06 +0100 Subject: [PATCH 091/223] refactor(project): Fix HashTree tests --- .../project/lib/build/cache/index/HashTree.js | 76 ------ .../lib/build/cache/index/SharedHashTree.js | 77 ++++++ .../lib/build/cache/ResourceRequestGraph.js | 31 +-- .../test/lib/build/cache/index/HashTree.js | 224 ----------------- .../lib/build/cache/index/SharedHashTree.js | 237 ++++++++++++++++++ 5 files changed, 316 insertions(+), 329 deletions(-) diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index 6f5743d87e1..dea4105c04a 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -711,80 +711,4 @@ export default class HashTree { traverse(this.root, "/"); return paths.sort(); } - - /** - * For a tree derived from a base tree, get the list of resource nodes - * that were added compared to the base tree. - * - * @param {HashTree} rootTree - The base tree to compare against - * @returns {Array} - * Array of added resource metadata - */ - getAddedResources(rootTree) { - const added = []; - - const traverse = (node, currentPath, implicitlyAdded = false) => { - if (implicitlyAdded) { - // We're in a subtree that's entirely new - add all resources - if (node.type === "resource") { - added.push({ - path: currentPath, - integrity: node.integrity, - size: node.size, - lastModified: node.lastModified, - inode: node.inode - }); - } - } else { - const baseNode = rootTree._findNode(currentPath); - if (baseNode && baseNode === node) { - // Node exists in base tree and is the same object (structural sharing) - // Neither node nor children are added - return; - } else if (baseNode && node.type === "directory") { - // Directory exists in both trees but may have been shallow-copied - // Check children individually - only process children that differ - for (const [name, child] of node.children) { - const childPath = currentPath ? path.join(currentPath, name) : name; - const baseChild = baseNode.children.get(name); - - if (!baseChild || baseChild !== child) { - // Child doesn't exist in base or is different - determine if added - if (!baseChild) { - // Entirely new - all descendants are added - traverse(child, childPath, true); - } else { - // Child was modified/replaced - recurse normally - traverse(child, childPath, false); - } - } - // If baseChild === child, skip it (shared) - } - return; // Don't continue with normal traversal - } else if (!baseNode && node.type === "resource") { - // Resource doesn't exist in base tree - it's added - added.push({ - path: currentPath, - integrity: node.integrity, - size: node.size, - lastModified: node.lastModified, - inode: node.inode - }); - return; - } else if (!baseNode && node.type === "directory") { - // Directory doesn't exist in base tree - all children are added - implicitlyAdded = true; - } - } - - if (node.type === "directory") { - for (const [name, child] of node.children) { - const childPath = currentPath ? path.join(currentPath, name) : name; - traverse(child, childPath, implicitlyAdded); - } - } - }; - traverse(this.root, "/"); - return added; - } } diff --git a/packages/project/lib/build/cache/index/SharedHashTree.js b/packages/project/lib/build/cache/index/SharedHashTree.js index 153b12b5d5c..abea227cd5b 100644 --- a/packages/project/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/lib/build/cache/index/SharedHashTree.js @@ -1,3 +1,4 @@ +import path from "node:path/posix"; import HashTree from "./HashTree.js"; import TreeNode from "./TreeNode.js"; @@ -101,6 +102,82 @@ export default class SharedHashTree extends HashTree { return derived; } + /** + * For a tree derived from a base tree, get the list of resource nodes + * that were added compared to the base tree. + * + * @param {HashTree} rootTree - The base tree to compare against + * @returns {Array} + * Array of added resource metadata + */ + getAddedResources(rootTree) { + const added = []; + + const traverse = (node, currentPath, implicitlyAdded = false) => { + if (implicitlyAdded) { + // We're in a subtree that's entirely new - add all resources + if (node.type === "resource") { + added.push({ + path: currentPath, + integrity: node.integrity, + size: node.size, + lastModified: node.lastModified, + inode: node.inode + }); + } + } else { + const baseNode = rootTree._findNode(currentPath); + if (baseNode && baseNode === node) { + // Node exists in base tree and is the same object (structural sharing) + // Neither node nor children are added + return; + } else if (baseNode && node.type === "directory") { + // Directory exists in both trees but may have been shallow-copied + // Check children individually - only process children that differ + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + const baseChild = baseNode.children.get(name); + + if (!baseChild || baseChild !== child) { + // Child doesn't exist in base or is different - determine if added + if (!baseChild) { + // Entirely new - all descendants are added + traverse(child, childPath, true); + } else { + // Child was modified/replaced - recurse normally + traverse(child, childPath, false); + } + } + // If baseChild === child, skip it (shared) + } + return; // Don't continue with normal traversal + } else if (!baseNode && node.type === "resource") { + // Resource doesn't exist in base tree - it's added + added.push({ + path: currentPath, + integrity: node.integrity, + size: node.size, + lastModified: node.lastModified, + inode: node.inode + }); + return; + } else if (!baseNode && node.type === "directory") { + // Directory doesn't exist in base tree - all children are added + implicitlyAdded = true; + } + } + + if (node.type === "directory") { + for (const [name, child] of node.children) { + const childPath = currentPath ? path.join(currentPath, name) : name; + traverse(child, childPath, implicitlyAdded); + } + } + }; + traverse(this.root, "/"); + return added; + } + /** * Deserialize tree from JSON * diff --git a/packages/project/test/lib/build/cache/ResourceRequestGraph.js b/packages/project/test/lib/build/cache/ResourceRequestGraph.js index b705c96625c..36ee2387c4d 100644 --- a/packages/project/test/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/test/lib/build/cache/ResourceRequestGraph.js @@ -14,18 +14,6 @@ test("Request: Create patterns request", (t) => { t.deepEqual(request.value, ["*.js", "*.css"]); }); -test("Request: Create dep-path request", (t) => { - const request = new Request("dep-path", "dependency/file.js"); - t.is(request.type, "dep-path"); - t.is(request.value, "dependency/file.js"); -}); - -test("Request: Create dep-patterns request", (t) => { - const request = new Request("dep-patterns", ["dep/*.js"]); - t.is(request.type, "dep-patterns"); - t.deepEqual(request.value, ["dep/*.js"]); -}); - test("Request: Reject invalid type", (t) => { const error = t.throws(() => { new Request("invalid-type", "value"); @@ -40,13 +28,6 @@ test("Request: Reject non-string value for path type", (t) => { t.is(error.message, "Request type 'path' requires value to be a string"); }); -test("Request: Reject non-string value for dep-path type", (t) => { - const error = t.throws(() => { - new Request("dep-path", ["array", "value"]); - }, {instanceOf: Error}); - t.is(error.message, "Request type 'dep-path' requires value to be a string"); -}); - test("Request: toKey with string value", (t) => { const request = new Request("path", "a.js"); t.is(request.toKey(), "path:a.js"); @@ -75,12 +56,6 @@ test("Request: equals returns true for identical requests", (t) => { t.true(req1.equals(req2)); }); -test("Request: equals returns false for different types", (t) => { - const req1 = new Request("path", "a.js"); - const req2 = new Request("dep-path", "a.js"); - t.false(req1.equals(req2)); -}); - test("Request: equals returns false for different values", (t) => { const req1 = new Request("path", "a.js"); const req2 = new Request("path", "b.js"); @@ -484,18 +459,16 @@ test("ResourceRequestGraph: Handles different request types", (t) => { const set1 = [ new Request("path", "a.js"), new Request("patterns", ["*.js"]), - new Request("dep-path", "dep/file.js"), - new Request("dep-patterns", ["dep/*.js"]) ]; const nodeId = graph.addRequestSet(set1); const node = graph.getNode(nodeId); const materialized = node.getMaterializedRequests(graph); - t.is(materialized.length, 4); + t.is(materialized.length, 2); const types = materialized.map((r) => r.type).sort(); - t.deepEqual(types, ["dep-path", "dep-patterns", "path", "patterns"]); + t.deepEqual(types, ["path", "patterns"]); }); test("ResourceRequestGraph: Complex parent hierarchy", (t) => { diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index 7617852893c..aa462840b69 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -502,227 +502,3 @@ test("removeResources - cleans up deeply nested empty directories", async (t) => t.truthy(tree._findNode("a"), "Directory a should still exist (has sibling.js)"); t.truthy(tree.hasPath("a/sibling.js"), "Sibling file should still exist"); }); - -test("deriveTree - copies only modified directories (copy-on-write)", (t) => { - const tree1 = new HashTree([ - {path: "shared/a.js", integrity: "hash-a"}, - {path: "shared/b.js", integrity: "hash-b"} - ]); - - // Derive a new tree (should share structure per design goal) - const tree2 = tree1.deriveTree([]); - - // Check if they share the "shared" directory node initially - const dir1Before = tree1.root.children.get("shared"); - const dir2Before = tree2.root.children.get("shared"); - - t.is(dir1Before, dir2Before, "Should share same directory node after deriveTree"); - - // Now insert into tree2 via the intended API (not directly) - tree2._insertResourceWithSharing("shared/c.js", {integrity: "hash-c"}); - - // Check what happened - const dir1After = tree1.root.children.get("shared"); - const dir2After = tree2.root.children.get("shared"); - - // EXPECTED BEHAVIOR (per copy-on-write): - // - Tree2 should copy "shared" directory to add "c.js" without affecting tree1 - // - dir2After !== dir1After (tree2 has its own copy) - // - dir1After === dir1Before (tree1 unchanged) - - t.is(dir1After, dir1Before, "Tree1 should be unaffected"); - t.not(dir2After, dir1After, "Tree2 should have its own copy after modification"); -}); - -test("deriveTree - preserves structural sharing for unmodified paths", (t) => { - const tree1 = new HashTree([ - {path: "shared/nested/deep/a.js", integrity: "hash-a"}, - {path: "other/b.js", integrity: "hash-b"} - ]); - - // Derive tree and add to "other" directory - const tree2 = tree1.deriveTree([]); - tree2._insertResourceWithSharing("other/c.js", {integrity: "hash-c"}); - - // The "shared" directory should still be shared (not copied) - // because we didn't modify it - const sharedDir1 = tree1.root.children.get("shared"); - const sharedDir2 = tree2.root.children.get("shared"); - - t.is(sharedDir1, sharedDir2, - "Unmodified 'shared' directory should remain shared between trees"); - - // But "other" should be copied (we modified it) - const otherDir1 = tree1.root.children.get("other"); - const otherDir2 = tree2.root.children.get("other"); - - t.not(otherDir1, otherDir2, - "Modified 'other' directory should be copied in tree2"); - - // Verify tree1 wasn't affected - t.false(tree1.hasPath("other/c.js"), "Tree1 should not have c.js"); - t.true(tree2.hasPath("other/c.js"), "Tree2 should have c.js"); -}); - -test("deriveTree - changes propagate to derived trees (shared view)", async (t) => { - const tree1 = new HashTree([ - {path: "shared/a.js", integrity: "hash-a", lastModified: 1000, size: 100} - ]); - - // Create derived tree - it's a view on the same data, not an independent copy - const tree2 = tree1.deriveTree([ - {path: "unique/b.js", integrity: "hash-b"} - ]); - - // Get reference to shared directory in both trees - const sharedDir1 = tree1.root.children.get("shared"); - const sharedDir2 = tree2.root.children.get("shared"); - - // By design: They SHOULD share the same node reference - t.is(sharedDir1, sharedDir2, "Trees share directory nodes (intentional design)"); - - // When tree1 is updated, tree2 sees the change (filtered view behavior) - const indexTimestamp = tree1.getIndexTimestamp(); - await tree1.upsertResources([ - createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) - ]); - - // Both trees see the update as per design - const node1 = tree1.root.children.get("shared").children.get("a.js"); - const node2 = tree2.root.children.get("shared").children.get("a.js"); - - t.is(node1, node2, "Same resource node (shared reference)"); - t.is(node1.integrity, "new-hash-a", "Tree1 sees update"); - t.is(node2.integrity, "new-hash-a", "Tree2 also sees update (intentional)"); - - // This is the intended behavior: derived trees are views, not snapshots - // Tree2 filters which resources it exposes, but underlying data is shared -}); - -// ============================================================================ -// getAddedResources Tests -// ============================================================================ - -test("getAddedResources - returns empty array when no resources added", (t) => { - const baseTree = new HashTree([ - {path: "a.js", integrity: "hash-a"}, - {path: "b.js", integrity: "hash-b"} - ]); - - const derivedTree = baseTree.deriveTree([]); - - const added = derivedTree.getAddedResources(baseTree); - - t.deepEqual(added, [], "Should return empty array when no resources added"); -}); - -test("getAddedResources - returns added resources from derived tree", (t) => { - const baseTree = new HashTree([ - {path: "a.js", integrity: "hash-a"}, - {path: "b.js", integrity: "hash-b"} - ]); - - const derivedTree = baseTree.deriveTree([ - {path: "c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, - {path: "d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} - ]); - - const added = derivedTree.getAddedResources(baseTree); - - t.is(added.length, 2, "Should return 2 added resources"); - t.deepEqual(added, [ - {path: "/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, - {path: "/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} - ], "Should return correct added resources with metadata"); -}); - -test("getAddedResources - handles nested directory additions", (t) => { - const baseTree = new HashTree([ - {path: "root/a.js", integrity: "hash-a"} - ]); - - const derivedTree = baseTree.deriveTree([ - {path: "root/nested/b.js", integrity: "hash-b", size: 100, lastModified: 1000, inode: 1}, - {path: "root/nested/c.js", integrity: "hash-c", size: 200, lastModified: 2000, inode: 2} - ]); - - const added = derivedTree.getAddedResources(baseTree); - - t.is(added.length, 2, "Should return 2 added resources"); - t.true(added.some((r) => r.path === "/root/nested/b.js"), "Should include nested b.js"); - t.true(added.some((r) => r.path === "/root/nested/c.js"), "Should include nested c.js"); -}); - -test("getAddedResources - handles new directory with multiple resources", (t) => { - const baseTree = new HashTree([ - {path: "src/a.js", integrity: "hash-a"} - ]); - - const derivedTree = baseTree.deriveTree([ - {path: "lib/b.js", integrity: "hash-b", size: 100, lastModified: 1000, inode: 1}, - {path: "lib/c.js", integrity: "hash-c", size: 200, lastModified: 2000, inode: 2}, - {path: "lib/nested/d.js", integrity: "hash-d", size: 300, lastModified: 3000, inode: 3} - ]); - - const added = derivedTree.getAddedResources(baseTree); - - t.is(added.length, 3, "Should return 3 added resources"); - t.true(added.some((r) => r.path === "/lib/b.js"), "Should include lib/b.js"); - t.true(added.some((r) => r.path === "/lib/c.js"), "Should include lib/c.js"); - t.true(added.some((r) => r.path === "/lib/nested/d.js"), "Should include nested resource"); -}); - -test("getAddedResources - preserves metadata for added resources", (t) => { - const baseTree = new HashTree([ - {path: "a.js", integrity: "hash-a"} - ]); - - const derivedTree = baseTree.deriveTree([ - {path: "b.js", integrity: "hash-b", size: 12345, lastModified: 9999, inode: 7777} - ]); - - const added = derivedTree.getAddedResources(baseTree); - - t.is(added.length, 1, "Should return 1 added resource"); - t.is(added[0].path, "/b.js", "Should have correct path"); - t.is(added[0].integrity, "hash-b", "Should preserve integrity"); - t.is(added[0].size, 12345, "Should preserve size"); - t.is(added[0].lastModified, 9999, "Should preserve lastModified"); - t.is(added[0].inode, 7777, "Should preserve inode"); -}); - -test("getAddedResources - handles mixed shared and added resources", (t) => { - const baseTree = new HashTree([ - {path: "shared/a.js", integrity: "hash-a"}, - {path: "shared/b.js", integrity: "hash-b"} - ]); - - const derivedTree = baseTree.deriveTree([ - {path: "shared/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, - {path: "unique/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} - ]); - - const added = derivedTree.getAddedResources(baseTree); - - t.is(added.length, 2, "Should return 2 added resources"); - t.true(added.some((r) => r.path === "/shared/c.js"), "Should include c.js in shared dir"); - t.true(added.some((r) => r.path === "/unique/d.js"), "Should include d.js in unique dir"); - t.false(added.some((r) => r.path === "/shared/a.js"), "Should not include shared a.js"); - t.false(added.some((r) => r.path === "/shared/b.js"), "Should not include shared b.js"); -}); - -test("getAddedResources - handles deeply nested additions", (t) => { - const baseTree = new HashTree([ - {path: "a.js", integrity: "hash-a"} - ]); - - const derivedTree = baseTree.deriveTree([ - {path: "dir1/dir2/dir3/dir4/deep.js", integrity: "hash-deep", size: 100, lastModified: 1000, inode: 1} - ]); - - const added = derivedTree.getAddedResources(baseTree); - - t.is(added.length, 1, "Should return 1 added resource"); - t.is(added[0].path, "/dir1/dir2/dir3/dir4/deep.js", "Should have correct deeply nested path"); - t.is(added[0].integrity, "hash-deep", "Should preserve integrity"); -}); diff --git a/packages/project/test/lib/build/cache/index/SharedHashTree.js b/packages/project/test/lib/build/cache/index/SharedHashTree.js index b5b265d78ee..78a7adfbe94 100644 --- a/packages/project/test/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/test/lib/build/cache/index/SharedHashTree.js @@ -183,6 +183,243 @@ test("SharedHashTree - deriveTree with empty resources", (t) => { t.true(tree2 instanceof SharedHashTree, "Should be SharedHashTree"); }); +test("deriveTree - copies only modified directories (copy-on-write)", (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ], registry); + + // Derive a new tree (should share structure per design goal) + const tree2 = tree1.deriveTree([]); + + // Check if they share the "shared" directory node initially + const dir1Before = tree1.root.children.get("shared"); + const dir2Before = tree2.root.children.get("shared"); + + t.is(dir1Before, dir2Before, "Should share same directory node after deriveTree"); + + // Now insert into tree2 via the intended API (not directly) + tree2._insertResourceWithSharing("shared/c.js", {integrity: "hash-c"}); + + // Check what happened + const dir1After = tree1.root.children.get("shared"); + const dir2After = tree2.root.children.get("shared"); + + // EXPECTED BEHAVIOR (per copy-on-write): + // - Tree2 should copy "shared" directory to add "c.js" without affecting tree1 + // - dir2After !== dir1After (tree2 has its own copy) + // - dir1After === dir1Before (tree1 unchanged) + + t.is(dir1After, dir1Before, "Tree1 should be unaffected"); + t.not(dir2After, dir1After, "Tree2 should have its own copy after modification"); +}); + +test("deriveTree - preserves structural sharing for unmodified paths", (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([ + {path: "shared/nested/deep/a.js", integrity: "hash-a"}, + {path: "other/b.js", integrity: "hash-b"} + ], registry); + + // Derive tree and add to "other" directory + const tree2 = tree1.deriveTree([]); + tree2._insertResourceWithSharing("other/c.js", {integrity: "hash-c"}); + + // The "shared" directory should still be shared (not copied) + // because we didn't modify it + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + + t.is(sharedDir1, sharedDir2, + "Unmodified 'shared' directory should remain shared between trees"); + + // But "other" should be copied (we modified it) + const otherDir1 = tree1.root.children.get("other"); + const otherDir2 = tree2.root.children.get("other"); + + t.not(otherDir1, otherDir2, + "Modified 'other' directory should be copied in tree2"); + + // Verify tree1 wasn't affected + t.false(tree1.hasPath("other/c.js"), "Tree1 should not have c.js"); + t.true(tree2.hasPath("other/c.js"), "Tree2 should have c.js"); +}); + +test("deriveTree - changes propagate to derived trees (shared view)", async (t) => { + const registry = new TreeRegistry(); + const tree1 = new SharedHashTree([ + {path: "shared/a.js", integrity: "hash-a", lastModified: 1000, size: 100} + ], registry); + + // Create derived tree - it's a view on the same data, not an independent copy + const tree2 = tree1.deriveTree([ + {path: "unique/b.js", integrity: "hash-b"} + ]); + + // Get reference to shared directory in both trees + const sharedDir1 = tree1.root.children.get("shared"); + const sharedDir2 = tree2.root.children.get("shared"); + + // By design: They SHOULD share the same node reference + t.is(sharedDir1, sharedDir2, "Trees share directory nodes (intentional design)"); + + // When tree1 is updated, tree2 sees the change (filtered view behavior) + const indexTimestamp = tree1.getIndexTimestamp(); + await tree1.upsertResources([ + createMockResource("shared/a.js", "new-hash-a", indexTimestamp + 1, 101, 1) + ]); + await registry.flush(); + + // Both trees see the update as per design + const node1 = tree1.root.children.get("shared").children.get("a.js"); + const node2 = tree2.root.children.get("shared").children.get("a.js"); + + t.is(node1, node2, "Same resource node (shared reference)"); + t.is(node1.integrity, "new-hash-a", "Tree1 sees update"); + t.is(node2.integrity, "new-hash-a", "Tree2 also sees update (intentional)"); + + // This is the intended behavior: derived trees are views, not snapshots + // Tree2 filters which resources it exposes, but underlying data is shared +}); + + +// ============================================================================ +// getAddedResources Tests +// ============================================================================ + +test("getAddedResources - returns empty array when no resources added", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ], registry); + + const derivedTree = baseTree.deriveTree([]); + + const added = derivedTree.getAddedResources(baseTree); + + t.deepEqual(added, [], "Should return empty array when no resources added"); +}); + +test("getAddedResources - returns added resources from derived tree", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"}, + {path: "b.js", integrity: "hash-b"} + ], registry); + + const derivedTree = baseTree.deriveTree([ + {path: "c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, + {path: "d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 2, "Should return 2 added resources"); + t.deepEqual(added, [ + {path: "/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, + {path: "/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + ], "Should return correct added resources with metadata"); +}); + +test("getAddedResources - handles nested directory additions", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "root/a.js", integrity: "hash-a"} + ], registry); + + const derivedTree = baseTree.deriveTree([ + {path: "root/nested/b.js", integrity: "hash-b", size: 100, lastModified: 1000, inode: 1}, + {path: "root/nested/c.js", integrity: "hash-c", size: 200, lastModified: 2000, inode: 2} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 2, "Should return 2 added resources"); + t.true(added.some((r) => r.path === "/root/nested/b.js"), "Should include nested b.js"); + t.true(added.some((r) => r.path === "/root/nested/c.js"), "Should include nested c.js"); +}); + +test("getAddedResources - handles new directory with multiple resources", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "src/a.js", integrity: "hash-a"} + ], registry); + + const derivedTree = baseTree.deriveTree([ + {path: "lib/b.js", integrity: "hash-b", size: 100, lastModified: 1000, inode: 1}, + {path: "lib/c.js", integrity: "hash-c", size: 200, lastModified: 2000, inode: 2}, + {path: "lib/nested/d.js", integrity: "hash-d", size: 300, lastModified: 3000, inode: 3} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 3, "Should return 3 added resources"); + t.true(added.some((r) => r.path === "/lib/b.js"), "Should include lib/b.js"); + t.true(added.some((r) => r.path === "/lib/c.js"), "Should include lib/c.js"); + t.true(added.some((r) => r.path === "/lib/nested/d.js"), "Should include nested resource"); +}); + +test("getAddedResources - preserves metadata for added resources", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"} + ], registry); + + const derivedTree = baseTree.deriveTree([ + {path: "b.js", integrity: "hash-b", size: 12345, lastModified: 9999, inode: 7777} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 1, "Should return 1 added resource"); + t.is(added[0].path, "/b.js", "Should have correct path"); + t.is(added[0].integrity, "hash-b", "Should preserve integrity"); + t.is(added[0].size, 12345, "Should preserve size"); + t.is(added[0].lastModified, 9999, "Should preserve lastModified"); + t.is(added[0].inode, 7777, "Should preserve inode"); +}); + +test("getAddedResources - handles mixed shared and added resources", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "shared/a.js", integrity: "hash-a"}, + {path: "shared/b.js", integrity: "hash-b"} + ], registry); + + const derivedTree = baseTree.deriveTree([ + {path: "shared/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, + {path: "unique/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 2, "Should return 2 added resources"); + t.true(added.some((r) => r.path === "/shared/c.js"), "Should include c.js in shared dir"); + t.true(added.some((r) => r.path === "/unique/d.js"), "Should include d.js in unique dir"); + t.false(added.some((r) => r.path === "/shared/a.js"), "Should not include shared a.js"); + t.false(added.some((r) => r.path === "/shared/b.js"), "Should not include shared b.js"); +}); + +test("getAddedResources - handles deeply nested additions", (t) => { + const registry = new TreeRegistry(); + const baseTree = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"} + ], registry); + + const derivedTree = baseTree.deriveTree([ + {path: "dir1/dir2/dir3/dir4/deep.js", integrity: "hash-deep", size: 100, lastModified: 1000, inode: 1} + ]); + + const added = derivedTree.getAddedResources(baseTree); + + t.is(added.length, 1, "Should return 1 added resource"); + t.is(added[0].path, "/dir1/dir2/dir3/dir4/deep.js", "Should have correct deeply nested path"); + t.is(added[0].integrity, "hash-deep", "Should preserve integrity"); +}); + + // ============================================================================ // SharedHashTree with Registry Integration Tests // ============================================================================ From 00fa0234faabc1f48c96940ee1843121926e56b1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 16 Jan 2026 15:31:14 +0100 Subject: [PATCH 092/223] refactor(project): Fix handling cache handling of removed resources --- .../lib/build/cache/ProjectBuildCache.js | 23 +++++++++---------- .../lib/build/cache/ResourceRequestManager.js | 6 ++++- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index e7605309bd7..e7dab3246dd 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -680,19 +680,18 @@ export default class ProjectBuildCache { async #updateSourceIndex(changedResourcePaths) { const sourceReader = this.#project.getSourceReader(); - const resources = await Promise.all(changedResourcePaths.map((resourcePath) => { - return sourceReader.byPath(resourcePath); - })); - const removedResources = []; - const foundResources = resources.filter((resource) => { - if (!resource) { - removedResources.push(resource); - return false; + const resources = []; + const removedResourcePaths = []; + await Promise.all(changedResourcePaths.map(async (resourcePath) => { + const resource = await sourceReader.byPath(resourcePath); + if (resource) { + resources.push(resource); + } else { + removedResourcePaths.push(resourcePath); } - return true; - }); - const {removed} = await this.#sourceIndex.removeResources(removedResources); - const {added, updated} = await this.#sourceIndex.upsertResources(foundResources, Date.now()); + })); + const {removed} = await this.#sourceIndex.removeResources(removedResourcePaths); + const {added, updated} = await this.#sourceIndex.upsertResources(resources, Date.now()); if (removed.length || added.length || updated.length) { log.verbose(`Source resource index for project ${this.#project.getName()} updated: ` + diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 4ae8880ce0a..19e3eb64a41 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -336,7 +336,11 @@ class ResourceRequestManager { let changedPaths; if (diff) { const {added, updated, removed} = diff; - changedPaths = Array.from(new Set([...added, ...updated, ...removed])); + if (removed.length) { + // Cannot use differential build if a resource has been removed + continue; + } + changedPaths = Array.from(new Set([...added, ...updated])); } else { changedPaths = []; } From d01f66da7c8c9c2ed149e198927a5fb61c4e40c9 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 20 Jan 2026 10:59:38 +0100 Subject: [PATCH 093/223] test(project): Add cases for theme.library.e with seperate less files --- .../lib/build/ProjectBuilder.integration.js | 73 +++++++++++++++++-- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 6086e2e5e2c..0aab8ebbaf0 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -222,9 +222,9 @@ test.serial("Build theme.library.e project multiple times", async (t) => { }); // Change a source file in theme.library.e - const changedFilePath = `${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/library.source.less`; - await fs.appendFile(changedFilePath, `\n.someNewClass {\n\tcolor: red;\n}\n`); - + const librarySourceFilePath = + `${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/library.source.less`; + await fs.appendFile(librarySourceFilePath, `\n.someNewClass {\n\tcolor: red;\n}\n`); // #3 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -232,7 +232,6 @@ test.serial("Build theme.library.e project multiple times", async (t) => { projects: {"theme.library.e": {}} } }); - // Check whether the changed file is in the destPath const builtFileContent = await fs.readFile( `${destPath}/resources/theme/library/e/themes/my_theme/library.source.less`, {encoding: "utf8"} @@ -241,7 +240,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { builtFileContent.includes(`.someNewClass`), "Build dest contains changed file content" ); - // Check whether the updated copyright replacement took place + // Check whether the build output contains the new CSS rule const builtCssContent = await fs.readFile( `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} ); @@ -250,11 +249,71 @@ test.serial("Build theme.library.e project multiple times", async (t) => { "Build dest contains new rule in library.css" ); - // #4 build (with cache, no changes) + // Add a new less file and import it in library.source.less + await fs.writeFile(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`, + `.someOtherNewClass {\n\tcolor: blue;\n}\n` + ); + await fs.appendFile(librarySourceFilePath, `\n@import "newImportFile.less";\n`); + // #4 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { - projects: {} + projects: {"theme.library.e": {}}, + } + }); + // Check whether the build output contains the import to the new file + const builtCssContent2 = await fs.readFile( + `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} + ); + t.true( + builtCssContent2.includes(`.someOtherNewClass`), + "Build dest contains new rule in library.css" + ); + + // #5 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {}, + } + }); + + // Change content of new less file + await fs.writeFile(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`, + `.someOtherNewClass {\n\tcolor: green;\n}\n` + ); + // #6 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"theme.library.e": {}}, + } + }); + // Check whether the build output contains the changed content of the imported file + const builtCssContent3 = await fs.readFile( + `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} + ); + t.true( + builtCssContent3.includes(`.someOtherNewClass{color:green}`), + "Build dest contains new rule in library.css" + ); + + // Delete import of library.source.less + const librarySourceFileContent = (await fs.readFile(librarySourceFilePath)).toString(); + await fs.writeFile(librarySourceFilePath, + librarySourceFileContent.replace(`\n@import "newImportFile.less";\n`, "") + ); + // Change content of new less file again + await fs.writeFile(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`, + `.someOtherNewClass {\n\tcolor: yellow;\n}\n` + ); + // #7 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"theme.library.e": { + skippedTasks: ["buildThemes"] + }}, } }); }); From 9bc3a686e8de193e8d1649a783c0337845710e35 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 13:02:55 +0100 Subject: [PATCH 094/223] refactor(project): Update graph traversal --- packages/project/lib/graph/ProjectGraph.js | 81 +++ .../project/test/lib/graph/ProjectGraph.js | 540 ++++++++++++++++++ 2 files changed, 621 insertions(+) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 5a3b4576bcf..9e23647fee3 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -509,6 +509,87 @@ class ProjectGraph { })(); } + /** + * Generator function that traverses all dependencies of the given start project depth-first. + * Each dependency project is visited exactly once, and dependencies are fully explored + * before the dependent project is yielded (post-order traversal). + * In case a cycle is detected, an error is thrown. + * + * @public + * @generator + * @param {string|boolean} [startName] Name of the project to start the traversal at, + * or a boolean to set includeStartModule while using the root project as start. + * Defaults to the graph's root project. + * @param {boolean} [includeStartModule=false] Whether to include the start project itself in the results + * @yields {object} Object containing the project and its direct dependencies + * @yields {module:@ui5/project/specifications/Project} return.project The dependency project + * @yields {string[]} return.dependencies Array of direct dependency names for this project + * @throws {Error} If the start project cannot be found or if a cycle is detected + */ + * traverseDependenciesDepthFirst(startName, includeStartModule = false) { + if (typeof startName === "boolean") { + includeStartModule = startName; + startName = undefined; + } + if (!startName) { + startName = this._rootProjectName; + } else if (!this.getProject(startName)) { + throw new Error(`Failed to start graph traversal: Could not find project ${startName} in project graph`); + } + + const visited = Object.create(null); + const processing = Object.create(null); + + const traverse = function* (projectName, ancestors) { + this._checkCycle(ancestors, projectName); + + if (visited[projectName]) { + return; + } + + if (processing[projectName]) { + return; + } + + processing[projectName] = true; + const newAncestors = [...ancestors, projectName]; + const dependencies = this.getDependencies(projectName); + + for (const depName of dependencies) { + yield* traverse.call(this, depName, newAncestors); + } + + visited[projectName] = true; + processing[projectName] = false; + + if (includeStartModule || projectName !== startName) { + yield { + project: this.getProject(projectName), + dependencies + }; + } + }.bind(this); + + yield* traverse(startName, []); + } + + /** + * Generator function that traverses all projects that depend on the given start project. + * Traversal is breadth-first, visiting each dependent project exactly once. + * Projects are yielded in the order they are discovered as dependents. + * In case a cycle is detected, an error is thrown. + * + * @public + * @generator + * @param {string|boolean} [startName] Name of the project to start the traversal at, + * or a boolean to set includeStartModule while using the root project as start. + * Defaults to the graph's root project. + * @param {boolean} [includeStartModule=false] Whether to include the start project itself in the results + * @yields {object} Object containing the dependent project and its dependents + * @yields {module:@ui5/project/specifications/Project} return.project The dependent project + * @yields {string[]} return.dependents Array of project names that depend on this project + * @throws {Error} If the start project cannot be found or if a cycle is detected + */ * traverseDependents(startName, includeStartModule = false) { if (typeof startName === "boolean") { includeStartModule = startName; diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index 46295f96723..fdababc228c 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -1152,6 +1152,546 @@ test("traverseDepthFirst: Dependency declaration order is followed", async (t) = ]); }); +test("traverseDependenciesDepthFirst: Basic traversal without including start module", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.c"); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.c", + "library.b" + ], "Should traverse dependencies in depth-first order, excluding start module"); +}); + +test("traverseDependenciesDepthFirst: Basic traversal including start module", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.c"); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a", true)) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.c", + "library.b", + "library.a" + ], "Should traverse dependencies in depth-first order, including start module"); +}); + +test("traverseDependenciesDepthFirst: Using boolean as first parameter", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.c"); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst(true)) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.c", + "library.b", + "library.a" + ], "Should traverse from root and include root when boolean is passed as first parameter"); +}); + +test("traverseDependenciesDepthFirst: No dependencies", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [], "Should return empty results when project has no dependencies"); +}); + +test("traverseDependenciesDepthFirst: Diamond dependency structure", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.a", "library.c"); + graph.declareDependency("library.b", "library.d"); + graph.declareDependency("library.c", "library.d"); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.d", + "library.b", + "library.c" + ], "Should visit library.d once, then library.b, then library.c"); +}); + +test("traverseDependenciesDepthFirst: Complex dependency chain", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + graph.addProject(await createProject("library.e")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.c"); + graph.declareDependency("library.c", "library.d"); + graph.declareDependency("library.d", "library.e"); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.e", + "library.d", + "library.c", + "library.b" + ], "Should traverse entire dependency chain in depth-first order"); +}); + +test("traverseDependenciesDepthFirst: No project visited twice", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.a", "library.c"); + graph.declareDependency("library.b", "library.d"); + graph.declareDependency("library.c", "library.d"); + + const results = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + results.push(result.project.getName()); + } + + // library.d should appear only once + const dCount = results.filter(name => name === "library.d").length; + t.is(dCount, 1, "library.d should be visited exactly once"); + t.is(results.length, 3, "Should visit exactly 3 projects"); +}); + +test("traverseDependenciesDepthFirst: Can't find start node", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + + const error = t.throws(() => { + for (const result of graph.traverseDependenciesDepthFirst("library.nonexistent")) { + // Should not reach here + } + }); + t.is(error.message, + "Failed to start graph traversal: Could not find project library.nonexistent in project graph", + "Should throw with expected error message"); +}); + +test("traverseDependenciesDepthFirst: dependencies parameter", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.a", "library.c"); + graph.declareDependency("library.b", "library.d"); + + const results = []; + const dependencies = []; + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + results.push(result.project.getName()); + dependencies.push(result.dependencies); + } + + t.deepEqual(results, [ + "library.d", + "library.b", + "library.c" + ], "Should visit dependencies in depth-first order"); + + const dIndex = results.indexOf("library.d"); + const bIndex = results.indexOf("library.b"); + const cIndex = results.indexOf("library.c"); + + t.deepEqual(dependencies[dIndex], [], "library.d should have no dependencies"); + t.deepEqual(dependencies[bIndex], ["library.d"], "library.b should have library.d as dependency"); + t.deepEqual(dependencies[cIndex], [], "library.c should have no dependencies"); +}); + +test("traverseDependenciesDepthFirst: Detect cycle", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.a"); + + const error = t.throws(() => { + for (const result of graph.traverseDependenciesDepthFirst("library.a")) { + // Should not complete iteration + } + }); + t.is(error.message, + "Detected cyclic dependency chain: *library.a* -> library.b -> *library.a*", + "Should throw with expected error message"); +}); + +test("traverseDependenciesDepthFirst: Dependency declaration order is followed", async (t) => { + const {ProjectGraph} = t.context; + const graph1 = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph1.addProject(await createProject("library.a")); + graph1.addProject(await createProject("library.b")); + graph1.addProject(await createProject("library.c")); + graph1.addProject(await createProject("library.d")); + + graph1.declareDependency("library.a", "library.b"); + graph1.declareDependency("library.a", "library.c"); + graph1.declareDependency("library.a", "library.d"); + + const results1 = []; + for (const result of graph1.traverseDependenciesDepthFirst("library.a")) { + results1.push(result.project.getName()); + } + + t.deepEqual(results1, [ + "library.b", + "library.c", + "library.d" + ], "First graph should visit in declaration order"); + + const graph2 = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph2.addProject(await createProject("library.a")); + graph2.addProject(await createProject("library.b")); + graph2.addProject(await createProject("library.c")); + graph2.addProject(await createProject("library.d")); + + graph2.declareDependency("library.a", "library.d"); + graph2.declareDependency("library.a", "library.c"); + graph2.declareDependency("library.a", "library.b"); + + const results2 = []; + for (const result of graph2.traverseDependenciesDepthFirst("library.a")) { + results2.push(result.project.getName()); + } + + t.deepEqual(results2, [ + "library.d", + "library.c", + "library.b" + ], "Second graph should visit in reverse declaration order"); +}); + +test("traverseDependents: Basic traversal without including start module", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + + graph.declareDependency("library.b", "library.c"); + graph.declareDependency("library.a", "library.b"); + + const results = []; + for (const result of graph.traverseDependents("library.c")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.b", + "library.a" + ], "Should traverse dependents in correct order, excluding start module"); +}); + +test("traverseDependents: Basic traversal including start module", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + + graph.declareDependency("library.b", "library.c"); + graph.declareDependency("library.a", "library.b"); + + const results = []; + for (const result of graph.traverseDependents("library.c", true)) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.c", + "library.b", + "library.a" + ], "Should traverse dependents in correct order, including start module"); +}); + +test("traverseDependents: Using boolean as first parameter", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.c" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + + graph.declareDependency("library.a", "library.c"); + graph.declareDependency("library.b", "library.c"); + + const results = []; + for (const result of graph.traverseDependents(true)) { + results.push(result.project.getName()); + } + + t.deepEqual(results.sort(), [ + "library.a", + "library.b", + "library.c" + ].sort(), "Should traverse from root and include root when boolean is passed as first parameter"); +}); + +test("traverseDependents: No dependents", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + + graph.declareDependency("library.a", "library.b"); + + const results = []; + for (const result of graph.traverseDependents("library.a")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [], "Should return empty results when project has no dependents"); +}); + +test("traverseDependents: Multiple dependents", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + + graph.declareDependency("library.a", "library.d"); + graph.declareDependency("library.b", "library.d"); + graph.declareDependency("library.c", "library.d"); + + const results = []; + for (const result of graph.traverseDependents("library.d")) { + results.push(result.project.getName()); + } + + t.deepEqual(results.sort(), [ + "library.a", + "library.b", + "library.c" + ].sort(), "Should return all projects that depend on the target project"); +}); + +test("traverseDependents: Complex chain", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + graph.addProject(await createProject("library.e")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.c"); + graph.declareDependency("library.c", "library.d"); + graph.declareDependency("library.d", "library.e"); + + const results = []; + for (const result of graph.traverseDependents("library.e")) { + results.push(result.project.getName()); + } + + t.deepEqual(results, [ + "library.d", + "library.c", + "library.b", + "library.a" + ], "Should traverse entire dependent chain"); +}); + +test("traverseDependents: No project visited twice", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + + graph.declareDependency("library.a", "library.c"); + graph.declareDependency("library.b", "library.c"); + graph.declareDependency("library.a", "library.d"); + graph.declareDependency("library.b", "library.d"); + graph.declareDependency("library.c", "library.d"); + + const results = []; + for (const result of graph.traverseDependents("library.d")) { + results.push(result.project.getName()); + } + + t.deepEqual(results.sort(), [ + "library.a", + "library.b", + "library.c" + ].sort(), "Should visit each project exactly once"); +}); + +test("traverseDependents: Can't find start node", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + + const error = t.throws(() => { + // Consume the generator to trigger the error + for (const result of graph.traverseDependents("library.nonexistent")) { + // Should not reach here + } + }); + t.is(error.message, + "Failed to start graph traversal: Could not find project library.nonexistent in project graph", + "Should throw with expected error message"); +}); + +test("traverseDependents: dependents parameter", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + graph.addProject(await createProject("library.c")); + graph.addProject(await createProject("library.d")); + + graph.declareDependency("library.a", "library.d"); + graph.declareDependency("library.b", "library.d"); + graph.declareDependency("library.b", "library.c"); + graph.declareDependency("library.c", "library.d"); + + const results = []; + const dependents = []; + for (const result of graph.traverseDependents("library.d")) { + results.push(result.project.getName()); + dependents.push(result.dependents); + } + + t.deepEqual(results.sort(), [ + "library.a", + "library.b", + "library.c" + ].sort(), "Should visit all dependents"); + + // Check that dependents information is provided correctly + const aIndex = results.indexOf("library.a"); + const bIndex = results.indexOf("library.b"); + const cIndex = results.indexOf("library.c"); + + t.deepEqual(dependents[aIndex], [], "library.a should have no dependents"); + t.deepEqual(dependents[bIndex], [], "library.b should have no dependents"); + t.deepEqual(dependents[cIndex], ["library.b"], "library.c should have library.b as dependent"); +}); + +test("traverseDependents: Detect cycle", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + + graph.declareDependency("library.a", "library.b"); + graph.declareDependency("library.b", "library.a"); + + const error = t.throws(() => { + for (const result of graph.traverseDependents("library.a")) { + // Should not complete iteration + } + }); + t.is(error.message, + "Detected cyclic dependency chain: *library.a* -> library.b -> *library.a*", + "Should throw with expected error message"); +}); + test("join", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ From 168d4135f42ad4fbda5673cfa996173b7705d31f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 16 Jan 2026 16:42:14 +0100 Subject: [PATCH 095/223] refactor(project): Add BuildServer and BuildReader --- packages/project/lib/build/BuildReader.js | 92 +++++ packages/project/lib/build/BuildServer.js | 194 ++++++++++ packages/project/lib/build/ProjectBuilder.js | 330 +++++++++--------- .../project/lib/build/helpers/BuildContext.js | 71 ++-- .../lib/build/helpers/ProjectBuildContext.js | 4 +- .../project/lib/build/helpers/WatchHandler.js | 67 +--- .../lib/build/helpers/composeProjectList.js | 14 +- packages/project/lib/graph/ProjectGraph.js | 44 ++- .../project/lib/specifications/Project.js | 2 +- .../project/test/lib/graph/ProjectGraph.js | 2 +- 10 files changed, 552 insertions(+), 268 deletions(-) create mode 100644 packages/project/lib/build/BuildReader.js create mode 100644 packages/project/lib/build/BuildServer.js diff --git a/packages/project/lib/build/BuildReader.js b/packages/project/lib/build/BuildReader.js new file mode 100644 index 00000000000..f420ad2a171 --- /dev/null +++ b/packages/project/lib/build/BuildReader.js @@ -0,0 +1,92 @@ +import AbstractReader from "@ui5/fs/AbstractReader"; + +class BuildReader extends AbstractReader { + #projects; + #projectNames; + #namespaces = new Map(); + #getReaderForProject; + #getReaderForProjects; + + constructor(name, projects, getReaderForProject, getReaderForProjects) { + super(name); + this.#projects = projects; + this.#projectNames = projects.map((p) => p.getName()); + this.#getReaderForProject = getReaderForProject; + this.#getReaderForProjects = getReaderForProjects; + + for (const project of projects) { + const ns = project.getNamespace(); + // Not all projects have a namespace, e.g. modules or theme-libraries + if (ns) { + if (this.#namespaces.has(ns)) { + throw new Error(`Multiple projects with namespace '${ns}' found: ` + + `${this.#namespaces.get(ns)} and ${project.getName()}`); + } + this.#namespaces.set(ns, project.getName()); + } + } + } + + async byGlob(...args) { + const reader = await this.#getReaderForProjects(this.#projectNames); + return reader.byGlob(...args); + } + + async byPath(virPath, ...args) { + const reader = await this._getReaderForResource(virPath); + let res = await reader.byPath(virPath, ...args); + if (!res) { + // Fallback to unspecified projects + const allReader = await this.#getReaderForProjects(this.#projectNames); + res = await allReader.byPath(virPath, ...args); + } + return res; + } + + + async _getReaderForResource(virPath) { + let reader; + if (this.#projects.length === 1) { + // Filtering on a single project (typically the root project) + reader = await this.#getReaderForProject(this.#projectNames[0]); + } else { + // Determine project for resource path + const projects = this._getProjectsForResourcePath(virPath); + if (projects.length) { + reader = await this.#getReaderForProjects(projects); + } else { + // Unable to determine project for resource + // Request reader for all projects + reader = await this.#getReaderForProjects(this.#projectNames); + } + } + + return reader; + } + + /** + * Determine which projects might contain the resource for the given path. + * + * @param {string} virPath Virtual resource path + */ + _getProjectsForResourcePath(virPath) { + if (!virPath.startsWith("/resources/") && !virPath.startsWith("/test-resources/")) { + return []; + } + // Remove first two entries (e.g. "/resources/") + const parts = virPath.split("/").slice(2); + + const projectNames = []; + while (parts.length > 1) { + // Search for namespace, starting with the longest path + parts.pop(); + const ns = parts.join("/"); + if (this.#namespaces.has(ns)) { + projectNames.push(this.#namespaces.get(ns)); + } + } + return projectNames; + } +} + +export default BuildReader; diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js new file mode 100644 index 00000000000..d1e720342f9 --- /dev/null +++ b/packages/project/lib/build/BuildServer.js @@ -0,0 +1,194 @@ +import EventEmitter from "node:events"; +import {createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; +import BuildReader from "./BuildReader.js"; +import WatchHandler from "./helpers/WatchHandler.js"; + +class BuildServer extends EventEmitter { + #graph; + #projectBuilder; + #pCurrentBuild; + #allReader; + #rootReader; + #dependenciesReader; + #projectReaders = new Map(); + + constructor(graph, projectBuilder, initialBuildIncludedDependencies, initialBuildExcludedDependencies) { + super(); + this.#graph = graph; + this.#projectBuilder = projectBuilder; + this.#allReader = new BuildReader("Build Server: All Projects Reader", + Array.from(this.#graph.getProjects()), + this.#getReaderForProject.bind(this), + this.#getReaderForProjects.bind(this)); + const rootProject = this.#graph.getRoot(); + this.#rootReader = new BuildReader("Build Server: Root Project Reader", + [rootProject], + this.#getReaderForProject.bind(this), + this.#getReaderForProjects.bind(this)); + const dependencies = graph.getTransitiveDependencies(rootProject.getName()).map((dep) => graph.getProject(dep)); + this.#dependenciesReader = new BuildReader("Build Server: Dependencies Reader", + dependencies, + this.#getReaderForProject.bind(this), + this.#getReaderForProjects.bind(this)); + + if (initialBuildIncludedDependencies.length > 0) { + this.#pCurrentBuild = projectBuilder.build({ + includedDependencies: initialBuildIncludedDependencies, + excludedDependencies: initialBuildExcludedDependencies + }).then((builtProjects) => { + this.#projectBuildFinished(builtProjects); + }).catch((err) => { + this.emit("error", err); + }); + } + + const watchHandler = new WatchHandler(); + const allProjects = graph.getProjects(); + watchHandler.watch(allProjects).catch((err) => { + // Error during watch setup + this.emit("error", err); + }); + watchHandler.on("error", (err) => { + this.emit("error", err); + }); + watchHandler.on("sourcesChanged", (changes) => { + // Inform project builder + const affectedProjects = this.#projectBuilder.resourcesChanged(changes); + + for (const projectName of affectedProjects) { + this.#projectReaders.delete(projectName); + } + + const changedResourcePaths = [...changes.values()].flat(); + this.emit("sourcesChanged", changedResourcePaths); + }); + } + + getReader() { + return this.#allReader; + } + + getRootReader() { + return this.#rootReader; + } + + getDependenciesReader() { + return this.#dependenciesReader; + } + + async #getReaderForProject(projectName) { + if (this.#projectReaders.has(projectName)) { + return this.#projectReaders.get(projectName); + } + if (this.#pCurrentBuild) { + // If set, await currently running build + await this.#pCurrentBuild; + } + if (this.#projectReaders.has(projectName)) { + return this.#projectReaders.get(projectName); + } + this.#pCurrentBuild = this.#projectBuilder.build({ + includedDependencies: [projectName] + }).catch((err) => { + this.emit("error", err); + }); + const builtProjects = await this.#pCurrentBuild; + this.#projectBuildFinished(builtProjects); + + // Clear current build promise + this.#pCurrentBuild = null; + + return this.#projectReaders.get(projectName); + } + + async #getReaderForProjects(projectNames) { + let projectsRequiringBuild = []; + for (const projectName of projectNames) { + if (!this.#projectReaders.has(projectName)) { + projectsRequiringBuild.push(projectName); + } + } + if (projectsRequiringBuild.length === 0) { + // Projects already built + return this.#getReaderForCachedProjects(projectNames); + } + if (this.#pCurrentBuild) { + // If set, await currently running build + await this.#pCurrentBuild; + } + projectsRequiringBuild = []; + for (const projectName of projectNames) { + if (!this.#projectReaders.has(projectName)) { + projectsRequiringBuild.push(projectName); + } + } + if (projectsRequiringBuild.length === 0) { + // Projects already built + return this.#getReaderForCachedProjects(projectNames); + } + this.#pCurrentBuild = this.#projectBuilder.build({ + includedDependencies: projectsRequiringBuild + }).catch((err) => { + this.emit("error", err); + }); + const builtProjects = await this.#pCurrentBuild; + this.#projectBuildFinished(builtProjects); + + // Clear current build promise + this.#pCurrentBuild = null; + + return this.#getReaderForCachedProjects(projectNames); + } + + #getReaderForCachedProjects(projectNames) { + const readers = []; + for (const projectName of projectNames) { + const reader = this.#projectReaders.get(projectName); + if (reader) { + readers.push(reader); + } + } + return createReaderCollectionPrioritized({ + name: `Build Server: Reader for projects: ${projectNames.join(", ")}`, + readers + }); + } + + // async #getReaderForAllProjects() { + // if (this.#pCurrentBuild) { + // // If set, await initial build + // await this.#pCurrentBuild; + // } + // if (this.#allProjectsReader) { + // return this.#allProjectsReader; + // } + // this.#pCurrentBuild = this.#projectBuilder.build({ + // includedDependencies: ["*"] + // }).catch((err) => { + // this.emit("error", err); + // }); + // const builtProjects = await this.#pCurrentBuild; + // this.#projectBuildFinished(builtProjects); + + // // Clear current build promise + // this.#pCurrentBuild = null; + + // // Create a combined reader for all projects + // this.#allProjectsReader = createReaderCollectionPrioritized({ + // name: "All projects build reader", + // readers: [...this.#projectReaders.values()] + // }); + // return this.#allProjectsReader; + // } + + #projectBuildFinished(projectNames) { + for (const projectName of projectNames) { + this.#projectReaders.set(projectName, + this.#graph.getProject(projectName).getReader({style: "runtime"})); + } + this.emit("buildFinished", projectNames); + } +} + + +export default BuildServer; diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 5db2138de30..69ac852dca0 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -5,7 +5,6 @@ import composeProjectList from "./helpers/composeProjectList.js"; import BuildContext from "./helpers/BuildContext.js"; import prettyHrtime from "pretty-hrtime"; import OutputStyleEnum from "./helpers/ProjectBuilderOutputStyle.js"; -import createBuildManifest from "./helpers/createBuildManifest.js"; /** * @public @@ -14,6 +13,9 @@ import createBuildManifest from "./helpers/createBuildManifest.js"; */ class ProjectBuilder { #log; + #buildIsRunning = false; + // #resourceChanges = new Map(); + /** * Build Configuration * @@ -119,6 +121,35 @@ class ProjectBuilder { this.#log = new BuildLogger("ProjectBuilder"); } + resourcesChanged(changes) { + // if (!this.#resourceChanges.size) { + // this.#resourceChanges = changes; + // return; + // } + // for (const [project, resourcePaths] of changes.entries()) { + // if (!this.#resourceChanges.has(project.getName())) { + // this.#resourceChanges.set(project.getName(), []); + // } + // const projectChanges = this.#resourceChanges.get(project.getName()); + // projectChanges.push(...resourcePaths); + // } + + return this._buildContext.propagateResourceChanges(changes); + } + + // _flushResourceChanges() { + // this._buildContext.propagateResurceChanges(this.#resourceChanges); + // this.#resourceChanges = new Map(); + // } + + async build({ + includedDependencies = [], excludedDependencies = [], + }) { + const requestedProjects = this._determineRequestedProjects( + includedDependencies, excludedDependencies); + return await this.#build(requestedProjects); + } + /** * Executes a project build, including all necessary or requested dependencies * @@ -135,18 +166,17 @@ class ProjectBuilder { * Alternative to the includedDependencies and excludedDependencies parameters. * Allows for a more sophisticated configuration for defining which dependencies should be * part of the build result. If this is provided, the other mentioned parameters are ignored. - * @param {boolean} [parameters.watch] Whether to watch for file changes and re-execute the build automatically * @returns {Promise} Promise resolving once the build has finished */ - async build({ + async buildToTarget({ destPath, cleanDest = false, includedDependencies = [], excludedDependencies = [], dependencyIncludes, - watch, }) { - if (!destPath && !watch) { + if (!destPath) { throw new Error(`Missing parameter 'destPath'`); } + if (dependencyIncludes) { if (includedDependencies.length || excludedDependencies.length) { throw new Error( @@ -154,13 +184,27 @@ class ProjectBuilder { "with parameters 'includedDependencies' or 'excludedDependencies"); } } - const rootProjectName = this._graph.getRoot().getName(); - this.#log.info(`Preparing build for project ${rootProjectName}`); - this.#log.info(` Target directory: ${destPath}`); + this.#log.info(`Target directory: ${destPath}`); + const requestedProjects = this._determineRequestedProjects( + includedDependencies, excludedDependencies, dependencyIncludes); + + if (destPath && cleanDest) { + this.#log.info(`Cleaning target directory...`); + await rmrf(destPath); + } + const fsTarget = resourceFactory.createAdapter({ + fsBasePath: destPath, + virBasePath: "/" + }); + + await this.#build(requestedProjects, fsTarget); + } + + _determineRequestedProjects(includedDependencies, excludedDependencies, dependencyIncludes) { // Get project filter function based on include/exclude params // (also logs some info to console) - const filterProject = await this._getProjectFilter({ + const filterProject = this._createProjectFilter({ explicitIncludes: includedDependencies, explicitExcludes: excludedDependencies, dependencyIncludes @@ -180,20 +224,26 @@ class ProjectBuilder { } } - const projectBuildContexts = await this._buildContext.createRequiredProjectContexts(requestedProjects); - let fsTarget; - if (destPath) { - fsTarget = resourceFactory.createAdapter({ - fsBasePath: destPath, - virBasePath: "/" - }); + return requestedProjects; + } + + async #build(requestedProjects, fsTarget) { + if (this.#buildIsRunning) { + throw new Error("A build is already running"); } + this.#buildIsRunning = true; + const rootProjectName = this._graph.getRoot().getName(); + this.#log.info(`Preparing build for project ${rootProjectName}`); - const queue = []; + // this._flushResourceChanges(); + const projectBuildContexts = await this._buildContext.getRequiredProjectContexts(requestedProjects); // Create build queue based on graph depth-first search to ensure correct build order - await this._graph.traverseDepthFirst(async ({project}) => { + const queue = []; + const builtProjects = []; + for (const {project} of this._graph.traverseDependenciesDepthFirst(true)) { const projectName = project.getName(); + builtProjects.push(projectName); const projectBuildContext = projectBuildContexts.get(projectName); if (projectBuildContext) { // Build context exists @@ -201,76 +251,20 @@ class ProjectBuilder { // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); } - }); - - if (destPath && cleanDest) { - this.#log.info(`Cleaning target directory...`); - await rmrf(destPath); } - let pWatchInit; - if (watch) { - const relevantProjects = queue.map((projectBuildContext) => { - return projectBuildContext.getProject(); - }); - // Start watching already while the initial build is running - pWatchInit = this._buildContext.initWatchHandler(relevantProjects, async () => { - await this.#updateBuild(projectBuildContexts, requestedProjects, fsTarget); - }); - } - - await this.#build(queue, projectBuildContexts, requestedProjects, fsTarget); - - if (watch) { - const watchHandler = await pWatchInit; - watchHandler.setReady(); - return watchHandler; - } else { - return null; - } - } - - async #build(queue, projectBuildContexts, requestedProjects, fsTarget) { this.#log.setProjects(queue.map((projectBuildContext) => { return projectBuildContext.getProject().getName(); })); const alreadyBuilt = []; for (const projectBuildContext of queue) { - if (!await projectBuildContext.possiblyRequiresBuild()) { + if (!projectBuildContext.possiblyRequiresBuild()) { const projectName = projectBuildContext.getProject().getName(); alreadyBuilt.push(projectName); } } - if (queue.length > 1) { // Do not log if only the root project is being built - this.#log.info(`Processing ${queue.length} projects`); - if (alreadyBuilt.length) { - this.#log.info(` Reusing build results of ${alreadyBuilt.length} projects`); - this.#log.info(` Building ${queue.length - alreadyBuilt.length} projects`); - } - if (this.#log.isLevelEnabled("verbose")) { - this.#log.verbose(` Required projects:`); - this.#log.verbose(` ${queue - .map((projectBuildContext) => { - const projectName = projectBuildContext.getProject().getName(); - let msg; - if (alreadyBuilt.includes(projectName)) { - const buildMetadata = projectBuildContext.getBuildMetadata(); - let buildAt = ""; - if (buildMetadata) { - const ts = new Date(buildMetadata.timestamp).toUTCString(); - buildAt = ` at ${ts}`; - } - msg = `*> ${projectName} /// already built${buildAt}`; - } else { - msg = `=> ${projectName}`; - } - return msg; - }) - .join("\n ")}`); - } - } const cleanupSigHooks = this._registerCleanupSigHooks(); try { const startTime = process.hrtime(); @@ -293,25 +287,18 @@ class ProjectBuilder { await this._buildProject(projectBuildContext); } } - if (!requestedProjects.includes(projectName)) { - // Project has not been requested - // => Its resources shall not be part of the build result - continue; - } - - if (fsTarget) { - this.#log.verbose(`Writing out files...`); - pWrites.push(this._writeResults(projectBuildContext, fsTarget)); - } if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_CACHE_UPDATE) { - this.#log.verbose(`Triggering cache write...`); - // const buildManifest = await createBuildManifest( - // project, - // this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), - // projectBuildContext.getBuildSignature()); + this.#log.verbose(`Triggering cache update for project ${projectName}...`); pWrites.push(projectBuildContext.getBuildCache().writeCache()); } + + if (fsTarget && requestedProjects.includes(projectName)) { + // Only write requested projects to target + // (excluding dependencies that were required to be built, but not requested) + this.#log.verbose(`Writing out files for project ${projectName}...`); + pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + } } await Promise.all(pWrites); this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); @@ -322,84 +309,86 @@ class ProjectBuilder { this._deregisterCleanupSigHooks(cleanupSigHooks); await this._executeCleanupTasks(); } + this.#buildIsRunning = false; + return builtProjects; } - async #updateBuild(projectBuildContexts, requestedProjects, fsTarget) { - const cleanupSigHooks = this._registerCleanupSigHooks(); - try { - const startTime = process.hrtime(); - await this.#update(projectBuildContexts, requestedProjects, fsTarget); - this.#log.info(`Update succeeded in ${this._getElapsedTime(startTime)}`); - } catch (err) { - this.#log.error(`Update failed`); - throw err; - } finally { - this._deregisterCleanupSigHooks(cleanupSigHooks); - await this._executeCleanupTasks(); - } - } - - async #update(projectBuildContexts, requestedProjects, fsTarget) { - const queue = []; - await this._graph.traverseDepthFirst(async ({project}) => { - const projectName = project.getName(); - const projectBuildContext = projectBuildContexts.get(projectName); - if (projectBuildContext) { - // Build context exists - // => This project needs to be built or, in case it has already - // been built, it's build result needs to be written out (if requested) - queue.push(projectBuildContext); - } - }); - - this.#log.setProjects(queue.map((projectBuildContext) => { - return projectBuildContext.getProject().getName(); - })); - - const pWrites = []; - while (queue.length) { - const projectBuildContext = queue.shift(); - const project = projectBuildContext.getProject(); - const projectName = project.getName(); - const projectType = project.getType(); - this.#log.verbose(`Updating project ${projectName}...`); - - let changedPaths = await projectBuildContext.prepareProjectBuildAndValidateCache(); - if (changedPaths) { - this.#log.skipProjectBuild(projectName, projectType); - } else { - changedPaths = await this._buildProject(projectBuildContext); - } - - if (changedPaths.length) { - for (const pbc of queue) { - // Propagate resource changes to following projects - pbc.getBuildCache().dependencyResourcesChanged(changedPaths); - } - } - if (!requestedProjects.includes(projectName)) { - // Project has not been requested - // => Its resources shall not be part of the build result - continue; - } - - if (fsTarget) { - this.#log.verbose(`Writing out files...`); - pWrites.push(this._writeResults(projectBuildContext, fsTarget)); - } - - if (process.env.UI5_BUILD_NO_CACHE_UPDATE) { - continue; - } - this.#log.verbose(`Triggering cache write...`); - // const buildManifest = await createBuildManifest( - // project, - // this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), - // projectBuildContext.getBuildSignature()); - pWrites.push(projectBuildContext.getBuildCache().writeCache()); - } - await Promise.all(pWrites); - } + // async #updateBuild(projectBuildContexts, requestedProjects, fsTarget) { + // const cleanupSigHooks = this._registerCleanupSigHooks(); + // try { + // const startTime = process.hrtime(); + // await this.#update(projectBuildContexts, requestedProjects, fsTarget); + // this.#log.info(`Update succeeded in ${this._getElapsedTime(startTime)}`); + // } catch (err) { + // this.#log.error(`Update failed`); + // throw err; + // } finally { + // this._deregisterCleanupSigHooks(cleanupSigHooks); + // await this._executeCleanupTasks(); + // } + // } + + // async #update(projectBuildContexts, requestedProjects, fsTarget) { + // const queue = []; + // // await this._graph.traverseDepthFirst(async ({project}) => { + // // const projectName = project.getName(); + // // const projectBuildContext = projectBuildContexts.get(projectName); + // // if (projectBuildContext) { + // // // Build context exists + // // // => This project needs to be built or, in case it has already + // // // been built, it's build result needs to be written out (if requested) + // // queue.push(projectBuildContext); + // // } + // // }); + + // // this.#log.setProjects(queue.map((projectBuildContext) => { + // // return projectBuildContext.getProject().getName(); + // // })); + + // const pWrites = []; + // while (queue.length) { + // const projectBuildContext = queue.shift(); + // const project = projectBuildContext.getProject(); + // const projectName = project.getName(); + // const projectType = project.getType(); + // this.#log.verbose(`Updating project ${projectName}...`); + + // let changedPaths = await projectBuildContext.prepareProjectBuildAndValidateCache(); + // if (changedPaths) { + // this.#log.skipProjectBuild(projectName, projectType); + // } else { + // changedPaths = await this._buildProject(projectBuildContext); + // } + + // if (changedPaths.length) { + // for (const pbc of queue) { + // // Propagate resource changes to following projects + // pbc.getBuildCache().dependencyResourcesChanged(changedPaths); + // } + // } + // if (!requestedProjects.includes(projectName)) { + // // Project has not been requested + // // => Its resources shall not be part of the build result + // continue; + // } + + // if (fsTarget) { + // this.#log.verbose(`Writing out files...`); + // pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + // } + + // if (process.env.UI5_BUILD_NO_CACHE_UPDATE) { + // continue; + // } + // this.#log.verbose(`Triggering cache write...`); + // // const buildManifest = await createBuildManifest( + // // project, + // // this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), + // // projectBuildContext.getBuildSignature()); + // pWrites.push(projectBuildContext.getBuildCache().writeCache()); + // } + // await Promise.all(pWrites); + // } async _buildProject(projectBuildContext) { const project = projectBuildContext.getProject(); @@ -413,12 +402,12 @@ class ProjectBuilder { return {changedResources}; } - async _getProjectFilter({ + _createProjectFilter({ dependencyIncludes, explicitIncludes, explicitExcludes }) { - const {includedDependencies, excludedDependencies} = await composeProjectList( + const {includedDependencies, excludedDependencies} = composeProjectList( this._graph, dependencyIncludes || { includeDependencyTree: explicitIncludes, @@ -486,7 +475,10 @@ class ProjectBuilder { const resources = await reader.byGlob("/**/*"); if (createBuildManifest) { - // Create and write a build manifest metadata file + // Create and write a build manifest metadata file+ + const { + default: createBuildManifest + } = await import("./helpers/createBuildManifest.js"); const buildManifest = await createBuildManifest( project, this._graph, buildConfig, this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index a4ec790f48f..438916cf359 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -1,6 +1,5 @@ import ProjectBuildContext from "./ProjectBuildContext.js"; import OutputStyleEnum from "./ProjectBuilderOutputStyle.js"; -import WatchHandler from "./WatchHandler.js"; import CacheManager from "../cache/CacheManager.js"; import {getBaseSignature} from "./getBuildSignature.js"; @@ -83,7 +82,7 @@ class BuildContext { this._options = { cssVariables: cssVariables }; - this._projectBuildContexts = []; + this._projectBuildContexts = new Map(); } getRootProject() { @@ -106,21 +105,23 @@ class BuildContext { return this._graph; } - async createProjectContext({project}) { + async getProjectContext(projectName) { + if (this._projectBuildContexts.has(projectName)) { + return this._projectBuildContexts.get(projectName); + } + const project = this._graph.getProject(projectName); const projectBuildContext = await ProjectBuildContext.create( this, project, await this.getCacheManager(), this._buildSignatureBase); - this._projectBuildContexts.push(projectBuildContext); + this._projectBuildContexts.set(projectName, projectBuildContext); return projectBuildContext; } - async createRequiredProjectContexts(requestedProjects) { + async getRequiredProjectContexts(requestedProjects) { const projectBuildContexts = new Map(); const requiredProjects = new Set(requestedProjects); for (const projectName of requiredProjects) { - const projectBuildContext = await this.createProjectContext({ - project: this._graph.getProject(projectName) - }); + const projectBuildContext = await this.getProjectContext(projectName); projectBuildContexts.set(projectName, projectBuildContext); @@ -135,17 +136,6 @@ class BuildContext { return projectBuildContexts; } - async initWatchHandler(projects, updateBuildResult) { - const watchHandler = new WatchHandler(this, updateBuildResult); - await watchHandler.watch(projects); - this.#watchHandler = watchHandler; - return watchHandler; - } - - getWatchHandler() { - return this.#watchHandler; - } - async getCacheManager() { if (this.#cacheManager) { return this.#cacheManager; @@ -155,16 +145,51 @@ class BuildContext { } getBuildContext(projectName) { - if (projectName) { - return this._projectBuildContexts.find((ctx) => ctx.getProject().getName() === projectName); - } + return this._projectBuildContexts.get(projectName); } async executeCleanupTasks(force = false) { - await Promise.all(this._projectBuildContexts.map((ctx) => { + await Promise.all(Array.from(this._projectBuildContexts.values()).map((ctx) => { return ctx.executeCleanupTasks(force); })); } + + /** + * + * @param {Map>} resourceChanges + * @returns {Set} Names of projects potentially affected by the resource changes + */ + propagateResourceChanges(resourceChanges) { + const affectedProjectNames = new Set(); + const dependencyChanges = new Map(); + for (const [projectName, changedResourcePaths] of resourceChanges) { + affectedProjectNames.add(projectName); + // Propagate changes to dependents of the project + for (const {project: dep} of this._graph.traverseDependents(projectName)) { + const depChanges = dependencyChanges.get(dep.getName()); + if (!depChanges) { + dependencyChanges.set(dep.getName(), new Set(changedResourcePaths)); + continue; + } + for (const res of changedResourcePaths) { + depChanges.add(res); + } + } + const projectBuildContext = this.getBuildContext(projectName); + if (projectBuildContext) { + projectBuildContext.projectSourcesChanged(Array.from(changedResourcePaths)); + } + } + + for (const [projectName, changedResourcePaths] of dependencyChanges) { + affectedProjectNames.add(projectName); + const projectBuildContext = this.getBuildContext(projectName); + if (projectBuildContext) { + projectBuildContext.dependencyResourcesChanged(Array.from(changedResourcePaths)); + } + } + return affectedProjectNames; + } } export default BuildContext; diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 5cc25f8e0c9..d611d4fa6b0 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -165,9 +165,9 @@ class ProjectBuildContext { * * This method allows for an early check whether a project build can be skipped. * - * @returns {Promise} True if a build might required, false otherwise + * @returns {boolean} True if a build might required, false otherwise */ - async possiblyRequiresBuild() { + possiblyRequiresBuild() { if (this.#getBuildManifest()) { // Build manifest present -> No build required return false; diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index e85ee1b4e08..72ea95b65bc 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -10,23 +10,13 @@ const log = getLogger("build:helpers:WatchHandler"); * @memberof @ui5/project/build/helpers */ class WatchHandler extends EventEmitter { - #buildContext; - #updateBuildResult; #closeCallbacks = []; #sourceChanges = new Map(); #ready = false; - #updateInProgress = false; #fileChangeHandlerTimeout; - constructor(buildContext, updateBuildResult) { + constructor() { super(); - this.#buildContext = buildContext; - this.#updateBuildResult = updateBuildResult; - } - - setReady() { - this.#ready = true; - this.#processQueue(); } async watch(projects) { @@ -44,10 +34,11 @@ class WatchHandler extends EventEmitter { watcher.on("all", (event, filePath) => { this.#handleWatchEvents(event, filePath, project); }); - const {promise, resolve} = Promise.withResolvers(); + const {promise, resolve: ready} = Promise.withResolvers(); readyPromises.push(promise); watcher.on("ready", () => { - resolve(); + this.#ready = true; + ready(); }); watcher.on("error", (err) => { this.emit("error", err); @@ -56,7 +47,7 @@ class WatchHandler extends EventEmitter { return await Promise.all(readyPromises); } - async stop() { + async destroy() { for (const cb of this.#closeCallbacks) { await cb(); } @@ -80,12 +71,12 @@ class WatchHandler extends EventEmitter { } #processQueue() { - if (!this.#ready || this.#updateInProgress || !this.#sourceChanges.size) { - // Prevent concurrent or premature processing + if (!this.#ready || !this.#sourceChanges.size) { + // Prevent premature processing return; } - // Trigger callbacks debounced + // Trigger change event debounced if (this.#fileChangeHandlerTimeout) { clearTimeout(this.#fileChangeHandlerTimeout); } @@ -93,54 +84,16 @@ class WatchHandler extends EventEmitter { this.#fileChangeHandlerTimeout = null; const sourceChanges = this.#sourceChanges; - // Reset file changes before processing + // Reset file changes this.#sourceChanges = new Map(); - this.#updateInProgress = true; try { - await this.#handleResourceChanges(sourceChanges); + this.emit("sourcesChanged", sourceChanges); } catch (err) { this.emit("error", err); - } finally { - this.#updateInProgress = false; - } - - if (this.#sourceChanges.size > 0) { - // New changes have occurred during processing, trigger queue again - this.#processQueue(); } }, 100); } - - async #handleResourceChanges(sourceChanges) { - const dependencyChanges = new Map(); - - const graph = this.#buildContext.getGraph(); - for (const [projectName, changedResourcePaths] of sourceChanges) { - // Propagate changes to dependents of the project - for (const {project: dep} of graph.traverseDependents(projectName)) { - const depChanges = dependencyChanges.get(dep.getName()); - if (!depChanges) { - dependencyChanges.set(dep.getName(), new Set(changedResourcePaths)); - continue; - } - for (const res of changedResourcePaths) { - depChanges.add(res); - } - } - const projectBuildContext = this.#buildContext.getBuildContext(projectName); - projectBuildContext.projectSourcesChanged(Array.from(changedResourcePaths)); - } - - for (const [projectName, changedResourcePaths] of dependencyChanges) { - const projectBuildContext = this.#buildContext.getBuildContext(projectName); - projectBuildContext.dependencyResourcesChanged(Array.from(changedResourcePaths)); - } - - this.emit("projectResourcesInvalidated"); - await this.#updateBuildResult(); - this.emit("projectResourcesUpdated"); - } } export default WatchHandler; diff --git a/packages/project/lib/build/helpers/composeProjectList.js b/packages/project/lib/build/helpers/composeProjectList.js index d98ad17929e..0f4c5d6d01f 100644 --- a/packages/project/lib/build/helpers/composeProjectList.js +++ b/packages/project/lib/build/helpers/composeProjectList.js @@ -6,17 +6,17 @@ const log = getLogger("build:helpers:composeProjectList"); * its value is an array of all of its transitive dependencies. * * @param {@ui5/project/graph/ProjectGraph} graph - * @returns {Promise>} A promise resolving to an object with dependency names as + * @returns {Object} A promise resolving to an object with dependency names as * key and each with an array of its transitive dependencies as value */ -async function getFlattenedDependencyTree(graph) { +function getFlattenedDependencyTree(graph) { const dependencyMap = Object.create(null); const rootName = graph.getRoot().getName(); - await graph.traverseDepthFirst(({project, dependencies}) => { + for (const {project, dependencies} of graph.traverseDependenciesDepthFirst()) { if (project.getName() === rootName) { // Skip root project - return; + continue; } const projectDeps = []; dependencies.forEach((depName) => { @@ -26,7 +26,7 @@ async function getFlattenedDependencyTree(graph) { } }); dependencyMap[project.getName()] = projectDeps; - }); + } return dependencyMap; } @@ -41,7 +41,7 @@ async function getFlattenedDependencyTree(graph) { * @returns {{includedDependencies:string[],excludedDependencies:string[]}} An object containing the * 'includedDependencies' and 'excludedDependencies' */ -async function createDependencyLists(graph, { +function createDependencyLists(graph, { includeAllDependencies = false, includeDependency = [], includeDependencyRegExp = [], includeDependencyTree = [], excludeDependency = [], excludeDependencyRegExp = [], excludeDependencyTree = [], @@ -57,7 +57,7 @@ async function createDependencyLists(graph, { return {includedDependencies: [], excludedDependencies: []}; } - const flattenedDependencyTree = await getFlattenedDependencyTree(graph); + const flattenedDependencyTree = getFlattenedDependencyTree(graph); function isExcluded(excludeList, depName) { return excludeList && excludeList.has(depName); diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 9e23647fee3..a738eede4a2 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -713,7 +713,6 @@ class ProjectGraph { * @param {Array.} [parameters.excludedTasks=[]] List of tasks to be excluded. * @param {module:@ui5/project/build/ProjectBuilderOutputStyle} [parameters.outputStyle=Default] * Processes build results into a specific directory structure. - * @param {boolean} [parameters.watch] Whether to watch for file changes and re-execute the build automatically * @returns {Promise} Promise resolving to undefined once build has finished */ async build({ @@ -723,15 +722,14 @@ class ProjectGraph { selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], outputStyle = OutputStyleEnum.Default, - watch, }) { this.seal(); // Do not allow further changes to the graph - if (this._built) { + if (this._builtOrServed) { throw new Error( - `Project graph with root node ${this._rootProjectName} has already been built. ` + - `Each graph can only be built once`); + `Project graph with root node ${this._rootProjectName} has already been built or served. ` + + `Each graph can only be built or served once`); } - this._built = true; + this._builtOrServed = true; const { default: ProjectBuilder } = await import("../build/ProjectBuilder.js"); @@ -744,14 +742,44 @@ class ProjectGraph { includedTasks, excludedTasks, outputStyle, } }); - return await builder.build({ + return await builder.buildToTarget({ destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, - watch, }); } + async serve({ + initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], + selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, + includedTasks = [], excludedTasks = [], + }) { + this.seal(); // Do not allow further changes to the graph + if (this._builtOrServed) { + throw new Error( + `Project graph with root node ${this._rootProjectName} has already been built or served. ` + + `Each graph can only be built or served once`); + } + this._builtOrServed = true; + const { + default: ProjectBuilder + } = await import("../build/ProjectBuilder.js"); + const builder = new ProjectBuilder({ + graph: this, + taskRepository: await this._getTaskRepository(), + buildConfig: { + selfContained, cssVariables, jsdoc, + createBuildManifest, + includedTasks, excludedTasks, + outputStyle: OutputStyleEnum.Default, + } + }); + const { + default: BuildServer + } = await import("../build/BuildServer.js"); + return new BuildServer(this, builder, initialBuildIncludedDependencies, initialBuildExcludedDependencies); + } + /** * Seal the project graph so that no further changes can be made to it * diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 0dd06e4a290..a3620887f12 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -294,7 +294,7 @@ class Project extends Specification { } else { const currentReader = this.#currentStage.getCacheReader(); if (currentReader) { - readers.push(currentReader); + this._addReadersForWriter(readers, currentReader, style); } } } diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index fdababc228c..449a7d0bcec 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -1315,7 +1315,7 @@ test("traverseDependenciesDepthFirst: No project visited twice", async (t) => { } // library.d should appear only once - const dCount = results.filter(name => name === "library.d").length; + const dCount = results.filter((name) => name === "library.d").length; t.is(dCount, 1, "library.d should be visited exactly once"); t.is(results.length, 3, "Should visit exactly 3 projects"); }); From b8061d5c1d3d0bb42ada21835cac58d3be0534e7 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 11:39:45 +0100 Subject: [PATCH 096/223] refactor(project): Refactor project result cache Instead of storing it as a dedicated stage, save the relevant stage caches and import those --- packages/project/lib/build/ProjectBuilder.js | 2 +- .../project/lib/build/cache/CacheManager.js | 67 +++++- .../lib/build/cache/ProjectBuildCache.js | 225 ++++++++++++------ .../project/lib/specifications/Project.js | 63 ++--- 4 files changed, 239 insertions(+), 118 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 69ac852dca0..9ddd82c384f 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -243,13 +243,13 @@ class ProjectBuilder { const builtProjects = []; for (const {project} of this._graph.traverseDependenciesDepthFirst(true)) { const projectName = project.getName(); - builtProjects.push(projectName); const projectBuildContext = projectBuildContexts.get(projectName); if (projectBuildContext) { // Build context exists // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); + builtProjects.push(projectName); } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index d8088e3f4ee..85f0b5b75d3 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -20,7 +20,7 @@ const chacheManagerInstances = new Map(); const CACACHE_OPTIONS = {algorithms: ["sha256"]}; // Cache version for compatibility management -const CACHE_VERSION = "v0_0"; +const CACHE_VERSION = "v0_1"; /** * Manages persistence for the build cache using file-based storage and cacache @@ -48,6 +48,7 @@ export default class CacheManager { #manifestDir; #stageMetadataDir; #taskMetadataDir; + #resultMetadataDir; #indexDir; /** @@ -65,6 +66,7 @@ export default class CacheManager { this.#manifestDir = path.join(cacheDir, "buildManifests"); this.#stageMetadataDir = path.join(cacheDir, "stageMetadata"); this.#taskMetadataDir = path.join(cacheDir, "taskMetadata"); + this.#resultMetadataDir = path.join(cacheDir, "resultMetadata"); this.#indexDir = path.join(cacheDir, "index"); } @@ -342,6 +344,69 @@ export default class CacheManager { await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); } + /** + * Generates the file path for result metadata + * + * @private + * @param {string} packageName - Package/project identifier + * @param {string} buildSignature - Build signature hash + * @param {string} stageSignature - Stage signature hash (based on input resources) + * @returns {string} Absolute path to the stage metadata file + */ + #getResultMetadataPath(packageName, buildSignature, stageSignature) { + const pkgDir = getPathFromPackageName(packageName); + return path.join(this.#resultMetadataDir, pkgDir, buildSignature, `${stageSignature}.json`); + } + + /** + * Reads result metadata from cache + * + * Stage metadata contains information about resources produced by a build stage, + * including resource paths and their metadata. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {string} stageSignature - Stage signature hash (based on input resources) + * @returns {Promise} Parsed stage metadata or null if not found + * @throws {Error} If file read fails for reasons other than file not existing + */ + async readResultMetadata(projectId, buildSignature, stageSignature) { + try { + const metadata = await readFile( + this.#getResultMetadataPath(projectId, buildSignature, stageSignature + ), "utf8"); + return JSON.parse(metadata); + } catch (err) { + if (err.code === "ENOENT") { + // Cache miss + return null; + } + throw new Error(`Failed to read stage metadata from cache for ` + + `${projectId} / ${buildSignature} / ${stageSignature}: ${err.message}`, { + cause: err, + }); + } + } + + /** + * Writes result metadata to cache + * + * Persists metadata about resources produced by a build stage. + * Creates parent directories if needed. + * + * @param {string} projectId - Project identifier (typically package name) + * @param {string} buildSignature - Build signature hash + * @param {string} stageSignature - Stage signature hash (based on input resources) + * @param {object} metadata - Stage metadata object to serialize + * @returns {Promise} + */ + async writeResultMetadata(projectId, buildSignature, stageSignature, metadata) { + const metadataPath = this.#getResultMetadataPath( + projectId, buildSignature, stageSignature); + await mkdir(path.dirname(metadataPath), {recursive: true}); + await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + } + /** * Retrieves the file system path for a cached resource * diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index e7dab3246dd..adcb080ac76 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -1,4 +1,4 @@ -import {createResource, createProxy} from "@ui5/fs/resourceFactory"; +import {createResource, createProxy, createWriterCollection} from "@ui5/fs/resourceFactory"; import {getLogger} from "@ui5/logger"; import fs from "graceful-fs"; import {promisify} from "node:util"; @@ -42,7 +42,7 @@ export default class ProjectBuildCache { #currentDependencyReader; #sourceIndex; #cachedSourceSignature; - #currentDependencySignatures = new Map(); + #currentStageSignatures = new Map(); #cachedResultSignature; #currentResultSignature; @@ -177,7 +177,7 @@ export default class ProjectBuildCache { * If found, creates a reader for the cached stage and sets it as the project's * result stage. * - * @returns {Promise} + * @returns {Promise} Array of resource paths written by the cached result stage */ async #findResultCache() { if (this.#cacheState === CACHE_STATES.STALE && this.#currentResultSignature) { @@ -192,33 +192,66 @@ export default class ProjectBuildCache { `skipping result cache validation.`); return; } - const stageSignatures = this.#getPossibleResultStageSignatures(); - if (stageSignatures.includes(this.#currentResultSignature)) { + const resultSignatures = this.#getPossibleResultStageSignatures(); + if (resultSignatures.includes(this.#currentResultSignature)) { log.verbose( `Project ${this.#project.getName()} result stage signature unchanged: ${this.#currentResultSignature}`); this.#cacheState = CACHE_STATES.FRESH; return []; } - const stageCache = await this.#findStageCache("result", stageSignatures); - if (!stageCache) { + + const res = await firstTruthy(resultSignatures.map(async (resultSignature) => { + const metadata = await this.#cacheManager.readResultMetadata( + this.#project.getId(), this.#buildSignature, resultSignature); + if (!metadata) { + return; + } + return [resultSignature, metadata]; + })); + + if (!res) { log.verbose( - `No cached stage found for project ${this.#project.getName()}. Searching with ` + - `${stageSignatures.length} possible signatures.`); - // Cache state remains dirty - // this.#cacheState = CACHE_STATES.EMPTY; + `No cached stage found for project ${this.#project.getName()}. Searched with ` + + `${resultSignatures.length} possible signatures.`); return; } - const {stage, signature, writtenResourcePaths} = stageCache; + const [resultSignature, resultMetadata] = res; + log.verbose(`Found result cache with signature ${resultSignature}`); + const {stageSignatures} = resultMetadata; + + const writtenResourcePaths = await this.#importStages(stageSignatures); + log.verbose( - `Using cached result stage for project ${this.#project.getName()} with index signature ${signature}`); - this.#currentResultSignature = signature; - this.#cachedResultSignature = signature; - this.#project.setResultStage(stage); - this.#project.useResultStage(); + `Using cached result stage for project ${this.#project.getName()} with index signature ${resultSignature}`); + this.#currentResultSignature = resultSignature; + this.#cachedResultSignature = resultSignature; this.#cacheState = CACHE_STATES.FRESH; return writtenResourcePaths; } + async #importStages(stageSignatures) { + const stageNames = Object.keys(stageSignatures); + this.#project.initStages(stageNames); + const importedStages = await Promise.all(stageNames.map(async (stageName) => { + const stageSignature = stageSignatures[stageName]; + const stageCache = await this.#findStageCache(stageName, [stageSignature]); + if (!stageCache) { + throw new Error(`Inconsistent result cache: Could not find cached stage ` + + `${stageName} with signature ${stageSignature} for project ${this.#project.getName()}`); + } + return [stageName, stageCache]; + })); + this.#project.useResultStage(); + const writtenResourcePaths = new Set(); + for (const [stageName, stageCache] of importedStages) { + this.#project.setStage(stageName, stageCache.stage); + for (const resourcePath of stageCache.writtenResourcePaths) { + writtenResourcePaths.add(resourcePath); + } + } + return Array.from(writtenResourcePaths); + } + #getPossibleResultStageSignatures() { const projectSourceSignature = this.#sourceIndex.getSignature(); @@ -236,7 +269,11 @@ export default class ProjectBuildCache { #getResultStageSignature() { const projectSourceSignature = this.#sourceIndex.getSignature(); - const combinedDepSignature = createDependencySignature(Array.from(this.#currentDependencySignatures.values())); + const dependencySignatures = []; + for (const [, depSignature] of this.#currentStageSignatures.values()) { + dependencySignatures.push(depSignature); + } + const combinedDepSignature = createDependencySignature(dependencySignatures); return createStageSignature(projectSourceSignature, combinedDepSignature); } @@ -290,7 +327,7 @@ export default class ProjectBuildCache { const stageChanged = this.#project.setStage(stageName, stageCache.stage); // Store dependency signature for later use in result stage signature calculation - this.#currentDependencySignatures.set(taskName, stageCache.signature.split("-")[1]); + this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); // Cached stage might differ from the previous one // Add all resources written by the cached stage to the set of written/potentially changed resources @@ -334,7 +371,7 @@ export default class ProjectBuildCache { if (deltaStageCache) { // Store dependency signature for later use in result stage signature calculation const [foundProjectSig, foundDepSig] = deltaStageCache.signature.split("-"); - this.#currentDependencySignatures.set(taskName, foundDepSig); + this.#currentStageSignatures.set(stageName, [foundProjectSig, foundDepSig]); const projectDeltaInfo = projectDeltas.get(foundProjectSig); const dependencyDeltaInfo = depDeltas.get(foundDepSig); @@ -397,16 +434,44 @@ export default class ProjectBuildCache { const stageCache = await firstTruthy(stageSignatures.map(async (stageSignature) => { const stageMetadata = await this.#cacheManager.readStageCache( this.#project.getId(), this.#buildSignature, stageName, stageSignature); - if (stageMetadata) { - log.verbose(`Found cached stage with signature ${stageSignature}`); - const reader = this.#createReaderForStageCache( - stageName, stageSignature, stageMetadata.resourceMetadata); - return { - signature: stageSignature, - stage: reader, - writtenResourcePaths: Object.keys(stageMetadata.resourceMetadata), - }; + if (!stageMetadata) { + return; + } + log.verbose(`Found cached stage with signature ${stageSignature}`); + const {resourceMapping, resourceMetadata} = stageMetadata; + let writtenResourcePaths; + let stageReader; + if (resourceMapping) { + writtenResourcePaths = []; + // Restore writer collection + const readers = resourceMetadata.map((metadata) => { + writtenResourcePaths.push(...Object.keys(metadata)); + return this.#createReaderForStageCache( + stageName, stageSignature, metadata); + }); + + const writerMapping = Object.create(null); + for (const [resourcePath, metadataIndex] of Object.entries(resourceMapping)) { + if (!readers[metadataIndex]) { + throw new Error(`Inconsistent stage cache: No resource metadata ` + + `found at index ${metadataIndex} for resource ${resourcePath}`); + } + writerMapping[resourcePath] = readers[metadataIndex]; + } + + stageReader = createWriterCollection({ + name: `Restored cached stage ${stageName} for project ${this.#project.getName()}`, + writerMapping, + }); + } else { + writtenResourcePaths = Object.keys(resourceMetadata); + stageReader = this.#createReaderForStageCache(stageName, stageSignature, resourceMetadata); } + return { + signature: stageSignature, + stage: stageReader, + writtenResourcePaths, + }; })); return stageCache; } @@ -458,7 +523,7 @@ export default class ProjectBuildCache { } else { // Stage instance reader = cacheInfo.previousStageCache.stage.getWriter() ?? - cacheInfo.previousStageCache.stage.getReader(); + cacheInfo.previousStageCache.stage.getCachedWriter(); } const previousWrittenResources = await reader.byGlob("/**/*"); for (const res of previousWrittenResources) { @@ -475,7 +540,8 @@ export default class ProjectBuildCache { this.#currentDependencyReader ); // If provided, set dependency signature for later use in result stage signature calculation - this.#currentDependencySignatures.set(taskName, currentSignaturePair[1]); + const stageName = this.#getStageNameForTask(taskName); + this.#currentStageSignatures.set(stageName, currentSignaturePair); stageSignature = createStageSignature(...currentSignaturePair); } @@ -577,7 +643,6 @@ export default class ProjectBuildCache { // Reset updated resource paths this.#writtenResultResourcePaths = []; - this.#currentDependencySignatures = new Map(); return changedPaths; } @@ -724,7 +789,7 @@ export default class ProjectBuildCache { */ async writeCache(buildManifest) { await Promise.all([ - this.#writeResultStageCache(), + this.#writeResultCache(), this.#writeTaskStageCaches(), this.#writeTaskMetadataCaches(), @@ -734,7 +799,7 @@ export default class ProjectBuildCache { } /** - * Writes the result stage to persistent cache storage + * Writes the result metadata to persistent cache storage * * Collects all resources from the result stage (excluding source reader), * stores their content via the cache manager, and writes stage metadata @@ -742,38 +807,23 @@ export default class ProjectBuildCache { * * @returns {Promise} */ - async #writeResultStageCache() { + async #writeResultCache() { const stageSignature = this.#currentResultSignature; if (stageSignature === this.#cachedResultSignature) { // No changes to already cached result stage return; } - const stageId = "result"; - const deltaReader = this.#project.getReader({excludeSourceReader: true}); - const resources = await deltaReader.byGlob("/**/*"); - const resourceMetadata = Object.create(null); - log.verbose(`Writing result cache for project ${this.#project.getName()}:\n` + - `- Result stage signature is: ${stageSignature}\n` + - `- Cache state: ${this.#cacheState}\n` + - `- Storing ${resources.length} resources`); - - await Promise.all(resources.map(async (res) => { - // Store resource content in cacache via CacheManager - await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); - - resourceMetadata[res.getOriginalPath()] = { - inode: res.getInode(), - lastModified: res.getLastModified(), - size: await res.getSize(), - integrity: await res.getIntegrity(), - }; - })); + log.verbose(`Storing result metadata for project ${this.#project.getName()}`); + const stageSignatures = Object.create(null); + for (const [stageName, stageSigs] of this.#currentStageSignatures.entries()) { + stageSignatures[stageName] = stageSigs.join("-"); + } const metadata = { - resourceMetadata, + stageSignatures, }; - await this.#cacheManager.writeStageCache( - this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); + await this.#cacheManager.writeResultMetadata( + this.#project.getId(), this.#buildSignature, stageSignature, metadata); } async #writeTaskStageCaches() { @@ -787,29 +837,53 @@ export default class ProjectBuildCache { await Promise.all(stageQueue.map(async ([stageId, stageSignature]) => { const {stage} = this.#stageCache.getCacheForSignature(stageId, stageSignature); const writer = stage.getWriter(); - const reader = writer.collection ? writer.collection : writer; - const resources = await reader.byGlob("/**/*"); - const resourceMetadata = Object.create(null); - await Promise.all(resources.map(async (res) => { - // Store resource content in cacache via CacheManager - await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); - - resourceMetadata[res.getOriginalPath()] = { - inode: res.getInode(), - lastModified: res.getLastModified(), - size: await res.getSize(), - integrity: await res.getIntegrity(), - }; - })); - const metadata = { - resourceMetadata, - }; + let metadata; + if (writer.getMapping) { + const writerMapping = writer.getMapping(); + // Ensure unique readers are used + const readers = Array.from(new Set(Object.values(writerMapping))); + // Map mapping entries to reader indices + const resourceMapping = Object.create(null); + for (const [virPath, reader] of Object.entries(writerMapping)) { + const readerIdx = readers.indexOf(reader); + resourceMapping[virPath] = readerIdx; + } + + const resourceMetadata = await Promise.all(readers.map(async (reader, idx) => { + const resources = await reader.byGlob("/**/*"); + + return await this.#writeStageResources(resources, stageId, stageSignature); + })); + + metadata = {resourceMapping, resourceMetadata}; + } else { + const resources = await writer.byGlob("/**/*"); + const resourceMetadata = await this.#writeStageResources(resources, stageId, stageSignature); + metadata = {resourceMetadata}; + } + await this.#cacheManager.writeStageCache( this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); })); } + async #writeStageResources(resources, stageId, stageSignature) { + const resourceMetadata = Object.create(null); + await Promise.all(resources.map(async (res) => { + // Store resource content in cacache via CacheManager + await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + + resourceMetadata[res.getOriginalPath()] = { + inode: res.getInode(), + lastModified: res.getLastModified(), + size: await res.getSize(), + integrity: await res.getIntegrity(), + }; + })); + return resourceMetadata; + } + async #writeTaskMetadataCaches() { // Store task caches for (const [taskName, taskCache] of this.#taskCache) { @@ -903,6 +977,7 @@ export default class ProjectBuildCache { lastModified, integrity, inode, + project: this.#project, }); } }); diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index a3620887f12..dae4c8974d0 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -34,7 +34,6 @@ class Project extends Specification { } this._resourceTagCollection = null; - this._initStageMetadata(); } /** @@ -48,6 +47,7 @@ class Project extends Specification { async init(parameters) { await super.init(parameters); + this._initStageMetadata(); this._buildManifest = parameters.buildManifest; await this._configureAndValidatePaths(this._config); @@ -275,12 +275,11 @@ class Project extends Specification { * @param {object} [options] * @param {string} [options.style=buildtime] Path style to access resources. * Can be "buildtime", "dist", "runtime" or "flat" - * @param {boolean} [options.excludeSourceReader] If set to true, the source reader is omitted * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ - getReader({style = "buildtime", excludeSourceReader} = {}) { + getReader({style = "buildtime"} = {}) { let reader = this.#currentStageReaders.get(style); - if (reader && !excludeSourceReader) { + if (reader) { // Use cached reader return reader; } @@ -292,28 +291,25 @@ class Project extends Specification { if (currentWriter) { this._addReadersForWriter(readers, currentWriter, style); } else { - const currentReader = this.#currentStage.getCacheReader(); + const currentReader = this.#currentStage.getCachedWriter(); if (currentReader) { this._addReadersForWriter(readers, currentReader, style); } } } // Add readers for previous stages and source - readers.push(...this.#getReaders(style, excludeSourceReader)); + readers.push(...this.#getReaders(style)); reader = createReaderCollectionPrioritized({ name: `Reader collection for stage '${this.#currentStageId}' of project ${this.getName()}`, readers }); - if (excludeSourceReader) { - return reader; - } this.#currentStageReaders.set(style, reader); return reader; } - #getReaders(style = "buildtime", excludeSourceReader) { + #getReaders(style = "buildtime") { const readers = []; // Add writers for previous stages as readers @@ -324,11 +320,9 @@ class Project extends Specification { this.#addReaderForStage(this.#stages[i], readers, style); } - if (excludeSourceReader) { - return readers; - } // Finally add the project's source reader readers.push(this._getStyledReader(style)); + return readers; } @@ -347,7 +341,7 @@ class Project extends Specification { * @returns {@ui5/fs/DuplexCollection} DuplexCollection */ getWorkspace() { - if (this.#currentStage.getId() === RESULT_STAGE_ID) { + if (this.#currentStageId === RESULT_STAGE_ID) { throw new Error( `Workspace of project ${this.getName()} is currently not available. ` + `This might indicate that the project has already finished building ` + @@ -378,7 +372,7 @@ class Project extends Specification { * */ useResultStage() { - this.#currentStage = this.#stages.find((s) => s.getId() === RESULT_STAGE_ID); + this.#currentStage = null; this.#currentStageId = RESULT_STAGE_ID; this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages @@ -402,9 +396,9 @@ class Project extends Specification { if (writer) { this._addReadersForWriter(readers, writer, style); } else { - const reader = stage.getCacheReader(); + const reader = stage.getCachedWriter(); if (reader) { - readers.push(reader); + this._addReadersForWriter(readers, reader, style); } } } @@ -444,12 +438,12 @@ class Project extends Specification { this.#currentStageWorkspace = null; } - setStage(stageId, stageOrCacheReader) { + setStage(stageId, stageOrCachedWriter) { const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); if (stageIdx === -1) { throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); } - if (!stageOrCacheReader) { + if (!stageOrCachedWriter) { throw new Error( `Invalid stage or cache reader provided for stage '${stageId}' in project ${this.getName()}`); } @@ -459,14 +453,14 @@ class Project extends Specification { `Stage ID mismatch for stage '${stageId}' in project ${this.getName()}`); } let newStage; - if (stageOrCacheReader instanceof Stage) { - newStage = stageOrCacheReader; + if (stageOrCachedWriter instanceof Stage) { + newStage = stageOrCachedWriter; if (oldStage === newStage) { // Same stage as before return false; // Stored stage has not changed } } else { - newStage = new Stage(stageId, undefined, stageOrCacheReader); + newStage = new Stage(stageId, undefined, stageOrCachedWriter); } this.#stages[stageIdx] = newStage; @@ -480,19 +474,6 @@ class Project extends Specification { return true; // Indicate that the stored stage has changed } - setResultStage(stageOrCacheReader) { - this._initStageMetadata(); - - let resultStage; - if (stageOrCacheReader instanceof Stage) { - resultStage = stageOrCacheReader; - } else { - resultStage = new Stage(RESULT_STAGE_ID, undefined, stageOrCacheReader); - } - - this.#stages.push(resultStage); - } - /* Overwritten in ComponentProject subclass */ _addReadersForWriter(readers, writer, style) { readers.unshift(writer); @@ -530,16 +511,16 @@ class Project extends Specification { class Stage { #id; #writer; - #cacheReader; + #cachedWriter; - constructor(id, writer, cacheReader) { - if (writer && cacheReader) { + constructor(id, writer, cachedWriter) { + if (writer && cachedWriter) { throw new Error( `Stage '${id}' cannot have both a writer and a cache reader`); } this.#id = id; this.#writer = writer; - this.#cacheReader = cacheReader; + this.#cachedWriter = cachedWriter; } getId() { @@ -550,8 +531,8 @@ class Stage { return this.#writer; } - getCacheReader() { - return this.#cacheReader; + getCachedWriter() { + return this.#cachedWriter; } } From 1410621d20b6de68b2eb8d918346cea2b08cf073 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 13:00:34 +0100 Subject: [PATCH 097/223] refactor(server): Integrate BuildServer --- .../lib/middleware/MiddlewareManager.js | 6 +- .../server/lib/middleware/serveResources.js | 2 +- packages/server/lib/server.js | 78 ++++++++++--------- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/packages/server/lib/middleware/MiddlewareManager.js b/packages/server/lib/middleware/MiddlewareManager.js index 475dbec2420..10348b82154 100644 --- a/packages/server/lib/middleware/MiddlewareManager.js +++ b/packages/server/lib/middleware/MiddlewareManager.js @@ -21,17 +21,19 @@ const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty); * @alias @ui5/server/internal/MiddlewareManager */ class MiddlewareManager { - constructor({graph, rootProject, resources, options = { + constructor({graph, rootProject, sources, resources, buildReader, options = { sendSAPTargetCSP: false, serveCSPReports: false }}) { - if (!graph || !rootProject || !resources || !resources.all || + if (!graph || !rootProject || !sources || !resources || !resources.all || !resources.rootProject || !resources.dependencies) { throw new Error("[MiddlewareManager]: One or more mandatory parameters not provided"); } this.graph = graph; this.rootProject = rootProject; + this.sources = sources; this.resources = resources; + this.buildReader = buildReader; this.options = options; this.middleware = Object.create(null); diff --git a/packages/server/lib/middleware/serveResources.js b/packages/server/lib/middleware/serveResources.js index 74528972ada..e0a96ce2441 100644 --- a/packages/server/lib/middleware/serveResources.js +++ b/packages/server/lib/middleware/serveResources.js @@ -47,7 +47,7 @@ function createMiddleware({resources, middlewareUtil}) { // Pipe resource stream to response // TODO: Check whether we can optimize this for small or even all resources by using getBuffer() - resource.getStream().pipe(res); + res.send(await resource.getBuffer()); } catch (err) { next(err); } diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 54e1acd4aac..b5c02103b1e 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -138,51 +138,52 @@ export async function serve(graph, { acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false }) { const rootProject = graph.getRoot(); - const watchHandler = await graph.build({ - includedDependencies: ["*"], - watch: true, + + const readers = []; + await graph.traverseBreadthFirst(async function({project: dep}) { + if (dep.getName() === rootProject.getName()) { + // Ignore root project + return; + } + readers.push(dep.getSourceReader("runtime")); }); - async function createReaders() { - const readers = []; - await graph.traverseBreadthFirst(async function({project: dep}) { - if (dep.getName() === rootProject.getName()) { - // Ignore root project - return; - } - readers.push(dep.getReader({style: "runtime"})); - }); + const dependencies = createReaderCollection({ + name: `Dependency reader collection for sources of project ${rootProject.getName()}`, + readers + }); - const dependencies = createReaderCollection({ - name: `Dependency reader collection for project ${rootProject.getName()}`, - readers - }); + const rootReader = rootProject.getSourceReader("runtime"); - const rootReader = rootProject.getReader({style: "runtime"}); - // TODO change to ReaderCollection once duplicates are sorted out - const combo = new ReaderCollectionPrioritized({ - name: "server - prioritize workspace over dependencies", - readers: [rootReader, dependencies] - }); - const resources = { - rootProject: rootReader, - dependencies: dependencies, - all: combo - }; - return resources; + // TODO change to ReaderCollection once duplicates are sorted out + const combo = new ReaderCollectionPrioritized({ + name: "Server: Reader for sources of all projects", + readers: [rootReader, dependencies] + }); + const sources = { + rootProject: rootReader, + dependencies: dependencies, + all: combo + }; + + const initialBuildIncludedDependencies = []; + if (graph.getProject("sap.ui.core")) { + // Ensure sap.ui.core is always built initially (if present in the graph) + initialBuildIncludedDependencies.push("sap.ui.core"); } + const buildServer = await graph.serve({ + initialBuildIncludedDependencies, + excludedTasks: ["minify"], + }); - const resources = await createReaders(); + const resources = { + rootProject: buildServer.getRootReader(), + dependencies: buildServer.getDependenciesReader(), + all: buildServer.getReader(), + }; - watchHandler.on("projectResourcesUpdated", async () => { - const newResources = await createReaders(); - // Patch resources - resources.rootProject = newResources.rootProject; - resources.dependencies = newResources.dependencies; - resources.all = newResources.all; - }); - watchHandler.on("error", async (err) => { - log.error(`Watch handler error: ${err.message}`); + buildServer.on("error", async (err) => { + log.error(`Error during project build: ${err.message}`); log.verbose(err.stack); process.exit(1); }); @@ -190,6 +191,7 @@ export async function serve(graph, { const middlewareManager = new MiddlewareManager({ graph, rootProject, + sources, resources, options: { sendSAPTargetCSP, From 92861ec1c8a63d24fc4f8a35809b13f61f8c6fb4 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 14:42:56 +0100 Subject: [PATCH 098/223] refactor(project): Small build task cache restructuring, cleanup --- packages/project/lib/build/ProjectBuilder.js | 77 ------------------- .../project/lib/build/cache/CacheManager.js | 18 +++-- .../lib/build/cache/ProjectBuildCache.js | 31 +------- .../lib/specifications/ComponentProject.js | 17 ---- 4 files changed, 15 insertions(+), 128 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 9ddd82c384f..48fecb77e57 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -313,83 +313,6 @@ class ProjectBuilder { return builtProjects; } - // async #updateBuild(projectBuildContexts, requestedProjects, fsTarget) { - // const cleanupSigHooks = this._registerCleanupSigHooks(); - // try { - // const startTime = process.hrtime(); - // await this.#update(projectBuildContexts, requestedProjects, fsTarget); - // this.#log.info(`Update succeeded in ${this._getElapsedTime(startTime)}`); - // } catch (err) { - // this.#log.error(`Update failed`); - // throw err; - // } finally { - // this._deregisterCleanupSigHooks(cleanupSigHooks); - // await this._executeCleanupTasks(); - // } - // } - - // async #update(projectBuildContexts, requestedProjects, fsTarget) { - // const queue = []; - // // await this._graph.traverseDepthFirst(async ({project}) => { - // // const projectName = project.getName(); - // // const projectBuildContext = projectBuildContexts.get(projectName); - // // if (projectBuildContext) { - // // // Build context exists - // // // => This project needs to be built or, in case it has already - // // // been built, it's build result needs to be written out (if requested) - // // queue.push(projectBuildContext); - // // } - // // }); - - // // this.#log.setProjects(queue.map((projectBuildContext) => { - // // return projectBuildContext.getProject().getName(); - // // })); - - // const pWrites = []; - // while (queue.length) { - // const projectBuildContext = queue.shift(); - // const project = projectBuildContext.getProject(); - // const projectName = project.getName(); - // const projectType = project.getType(); - // this.#log.verbose(`Updating project ${projectName}...`); - - // let changedPaths = await projectBuildContext.prepareProjectBuildAndValidateCache(); - // if (changedPaths) { - // this.#log.skipProjectBuild(projectName, projectType); - // } else { - // changedPaths = await this._buildProject(projectBuildContext); - // } - - // if (changedPaths.length) { - // for (const pbc of queue) { - // // Propagate resource changes to following projects - // pbc.getBuildCache().dependencyResourcesChanged(changedPaths); - // } - // } - // if (!requestedProjects.includes(projectName)) { - // // Project has not been requested - // // => Its resources shall not be part of the build result - // continue; - // } - - // if (fsTarget) { - // this.#log.verbose(`Writing out files...`); - // pWrites.push(this._writeResults(projectBuildContext, fsTarget)); - // } - - // if (process.env.UI5_BUILD_NO_CACHE_UPDATE) { - // continue; - // } - // this.#log.verbose(`Triggering cache write...`); - // // const buildManifest = await createBuildManifest( - // // project, - // // this._graph, this._buildContext.getBuildConfig(), this._buildContext.getTaskRepository(), - // // projectBuildContext.getBuildSignature()); - // pWrites.push(projectBuildContext.getBuildCache().writeCache()); - // } - // await Promise.all(pWrites); - // } - async _buildProject(projectBuildContext) { const project = projectBuildContext.getProject(); const projectName = project.getName(); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 85f0b5b75d3..a2c6e476aab 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -291,11 +291,12 @@ export default class CacheManager { * @param {string} packageName - Package/project identifier * @param {string} buildSignature - Build signature hash * @param {string} taskName + * @param {string} type - "project" or "dependency" * @returns {string} Absolute path to the stage metadata file */ - #getTaskMetadataPath(packageName, buildSignature, taskName) { + #getTaskMetadataPath(packageName, buildSignature, taskName, type) { const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#taskMetadataDir, pkgDir, buildSignature, taskName, `metadata.json`); + return path.join(this.#taskMetadataDir, pkgDir, buildSignature, taskName, `${type}.json`); } /** @@ -307,12 +308,14 @@ export default class CacheManager { * @param {string} projectId - Project identifier (typically package name) * @param {string} buildSignature - Build signature hash * @param {string} taskName + * @param {string} type - "project" or "dependency" * @returns {Promise} Parsed stage metadata or null if not found * @throws {Error} If file read fails for reasons other than file not existing */ - async readTaskMetadata(projectId, buildSignature, taskName) { + async readTaskMetadata(projectId, buildSignature, taskName, type) { try { - const metadata = await readFile(this.#getTaskMetadataPath(projectId, buildSignature, taskName), "utf8"); + const metadata = await readFile( + this.#getTaskMetadataPath(projectId, buildSignature, taskName, type), "utf8"); return JSON.parse(metadata); } catch (err) { if (err.code === "ENOENT") { @@ -320,7 +323,7 @@ export default class CacheManager { return null; } throw new Error(`Failed to read task metadata from cache for ` + - `${projectId} / ${buildSignature} / ${taskName}: ${err.message}`, { + `${projectId} / ${buildSignature} / ${taskName} / ${type}: ${err.message}`, { cause: err, }); } @@ -335,11 +338,12 @@ export default class CacheManager { * @param {string} projectId - Project identifier (typically package name) * @param {string} buildSignature - Build signature hash * @param {string} taskName + * @param {string} type - "project" or "dependency" * @param {object} metadata - Stage metadata object to serialize * @returns {Promise} */ - async writeTaskMetadata(projectId, buildSignature, taskName, metadata) { - const metadataPath = this.#getTaskMetadataPath(projectId, buildSignature, taskName); + async writeTaskMetadata(projectId, buildSignature, taskName, type, metadata) { + const metadataPath = this.#getTaskMetadataPath(projectId, buildSignature, taskName, type); await mkdir(path.dirname(metadataPath), {recursive: true}); await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index adcb080ac76..7725d64b6f0 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -683,13 +683,13 @@ export default class ProjectBuildCache { const buildTaskCaches = await Promise.all( indexCache.tasks.map(async ([taskName, supportsDifferentialUpdates]) => { const projectRequests = await this.#cacheManager.readTaskMetadata( - this.#project.getId(), this.#buildSignature, `${taskName}-pr`); + this.#project.getId(), this.#buildSignature, taskName, "project"); if (!projectRequests) { throw new Error(`Failed to load project request cache for task ` + `${taskName} in project ${this.#project.getName()}`); } const dependencyRequests = await this.#cacheManager.readTaskMetadata( - this.#project.getId(), this.#buildSignature, `${taskName}-dr`); + this.#project.getId(), this.#buildSignature, taskName, "dependencies"); if (!dependencyRequests) { throw new Error(`Failed to load dependency request cache for task ` + `${taskName} in project ${this.#project.getName()}`); @@ -708,29 +708,6 @@ export default class ProjectBuildCache { } else { this.#cacheState = CACHE_STATES.INITIALIZED; } - // // Invalidate tasks based on changed resources - // // Note: If the changed paths don't affect any task, the index cache still can't be used due to the - // // root hash mismatch. - // // Since no tasks have been invalidated, a rebuild is still necessary in this case, so that - // // each task can find and use its individual stage cache. - // // Hence requiresInitialBuild will be set to true in this case (and others. - // // const tasksInvalidated = await this.#invalidateTasks(changedPaths, []); - // // if (!tasksInvalidated) { - - // // } - // } else if (indexCache.indexTree.root.hash !== resourceIndex.getSignature()) { - // // Validate index signature matches with cached signature - // throw new Error( - // `Resource index signature mismatch for project ${this.#project.getName()}: ` + - // `expected ${indexCache.indexTree.root.hash}, got ${resourceIndex.getSignature()}`); - // } - - // else { - // log.verbose( - // `Resource index signature for project ${this.#project.getName()} matches cached signature: ` + - // `${resourceIndex.getSignature()}`); - // // this.#cachedSourceSignature = resourceIndex.getSignature(); - // } this.#sourceIndex = resourceIndex; this.#cachedSourceSignature = resourceIndex.getSignature(); this.#changedProjectSourcePaths = changedPaths; @@ -894,11 +871,11 @@ export default class ProjectBuildCache { const writes = []; if (projectRequests) { writes.push(this.#cacheManager.writeTaskMetadata( - this.#project.getId(), this.#buildSignature, `${taskName}-pr`, projectRequests)); + this.#project.getId(), this.#buildSignature, taskName, "project", projectRequests)); } if (dependencyRequests) { writes.push(this.#cacheManager.writeTaskMetadata( - this.#project.getId(), this.#buildSignature, `${taskName}-dr`, dependencyRequests)); + this.#project.getId(), this.#buildSignature, taskName, "dependencies", dependencyRequests)); } await Promise.all(writes); } diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index cea92e5b6e6..d595d0085ff 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -150,23 +150,6 @@ class ComponentProject extends Project { throw new Error(`_getTestReader must be implemented by subclass ${this.constructor.name}`); } - // /** - // * Get a resource reader/writer for accessing and modifying a project's resources - // * - // * @public - // * @returns {@ui5/fs/ReaderCollection} A reader collection instance - // */ - // getWorkspace() { - // // Workspace is always of style "buildtime" - // // Therefore builder resource-excludes are always to be applied - // const excludes = this.getBuilderResourcesExcludes(); - // return resourceFactory.createWorkspace({ - // name: `Workspace for project ${this.getName()}`, - // reader: this._getPlainReader(excludes), - // writer: this._createWriter().collection - // }); - // } - _createWriter() { // writer is always of style "buildtime" const namespaceWriter = resourceFactory.createAdapter({ From 68125a3c1d8d40b894de56267284faba3412906b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 15:00:38 +0100 Subject: [PATCH 099/223] refactor(project): JSDoc cleanup --- .../lib/build/cache/ProjectBuildCache.js | 197 +++++++++++++----- .../lib/build/cache/ResourceRequestManager.js | 162 ++++++++++++-- 2 files changed, 293 insertions(+), 66 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 7725d64b6f0..16a9bb5725b 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -23,12 +23,14 @@ export const CACHE_STATES = Object.freeze({ /** * @typedef {object} StageMetadata * @property {Object} resourceMetadata + * Resource metadata indexed by resource path */ /** * @typedef {object} StageCacheEntry - * @property {@ui5/fs/AbstractReader} stage - Reader for the cached stage - * @property {string[]} writtenResourcePaths - Set of resource paths written by the task + * @property {string} signature Signature of the cached stage + * @property {@ui5/fs/AbstractReader} stage Reader for the cached stage + * @property {string[]} writtenResourcePaths Array of resource paths written by the task */ export default class ProjectBuildCache { @@ -55,11 +57,11 @@ export default class ProjectBuildCache { /** * Creates a new ProjectBuildCache instance + * Use ProjectBuildCache.create() instead * - * @private - Use ProjectBuildCache.create() instead - * @param {object} project - Project instance - * @param {string} buildSignature - Build signature for the current build - * @param {object} cacheManager - Cache manager instance for reading/writing cache data + * @param {@ui5/project/specifications/Project} project Project instance + * @param {string} buildSignature Build signature for the current build + * @param {object} cacheManager Cache manager instance for reading/writing cache data */ constructor(project, buildSignature, cacheManager) { log.verbose( @@ -75,10 +77,11 @@ export default class ProjectBuildCache { * This is the recommended way to create a ProjectBuildCache as it ensures * proper asynchronous initialization of the resource index and cache loading. * - * @param {object} project - Project instance - * @param {string} buildSignature - Build signature for the current build - * @param {object} cacheManager - Cache manager instance - * @returns {Promise} Initialized cache instance + * @public + * @param {@ui5/project/specifications/Project} project Project instance + * @param {string} buildSignature Build signature for the current build + * @param {object} cacheManager Cache manager instance + * @returns {Promise<@ui5/project/build/cache/ProjectBuildCache>} Initialized cache instance */ static async create(project, buildSignature, cacheManager) { const cache = new ProjectBuildCache(project, buildSignature, cacheManager); @@ -92,10 +95,12 @@ export default class ProjectBuildCache { * The dependency reader is used by tasks to access resources from project * dependencies. Must be set before tasks that require dependencies are executed. * - * @param {@ui5/fs/AbstractReader} dependencyReader - Reader for dependency resources - * @param {boolean} [forceDependencyUpdate=false] - * @returns {Promise} Undefined if no cache has been found. Otherwise a list of changed - * resources + * @public + * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources + * @param {boolean} [forceDependencyUpdate=false] Force update of dependency indices + * @returns {Promise} + * Undefined if no cache has been found, false if cache is empty, + * or an array of changed resource paths */ async prepareProjectBuildAndValidateCache(dependencyReader, forceDependencyUpdate = false) { this.#currentProjectReader = this.#project.getReader(); @@ -115,7 +120,9 @@ export default class ProjectBuildCache { /** * Processes changed resources since last build, updating indices and invalidating tasks as needed - */ + * + * @returns {Promise} + */ async #flushPendingChanges() { if (this.#changedProjectSourcePaths.length === 0 && this.#changedDependencyResourcePaths.length === 0) { @@ -150,6 +157,12 @@ export default class ProjectBuildCache { this.#changedDependencyResourcePaths = []; } + /** + * Updates dependency indices for all tasks + * + * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources + * @returns {Promise} + */ async #updateDependencyIndices(dependencyReader) { let depIndicesChanged = false; await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { @@ -166,6 +179,12 @@ export default class ProjectBuildCache { this.#changedDependencyResourcePaths = []; } + /** + * Checks whether the cache is in a fresh state + * + * @public + * @returns {boolean} True if the cache is fresh + */ isFresh() { return this.#cacheState === CACHE_STATES.FRESH; } @@ -177,7 +196,8 @@ export default class ProjectBuildCache { * If found, creates a reader for the cached stage and sets it as the project's * result stage. * - * @returns {Promise} Array of resource paths written by the cached result stage + * @returns {Promise} + * Array of resource paths written by the cached result stage, or undefined if no cache found */ async #findResultCache() { if (this.#cacheState === CACHE_STATES.STALE && this.#currentResultSignature) { @@ -229,6 +249,12 @@ export default class ProjectBuildCache { return writtenResourcePaths; } + /** + * Imports cached stages and sets them in the project + * + * @param {Object} stageSignatures Map of stage names to their signatures + * @returns {Promise} Array of resource paths written by all imported stages + */ async #importStages(stageSignatures) { const stageNames = Object.keys(stageSignatures); this.#project.initStages(stageNames); @@ -252,6 +278,11 @@ export default class ProjectBuildCache { return Array.from(writtenResourcePaths); } + /** + * Calculates all possible result stage signatures based on current state + * + * @returns {string[]} Array of possible result stage signatures + */ #getPossibleResultStageSignatures() { const projectSourceSignature = this.#sourceIndex.getSignature(); @@ -267,6 +298,11 @@ export default class ProjectBuildCache { }); } + /** + * Gets the current result stage signature + * + * @returns {string} Current result stage signature + */ #getResultStageSignature() { const projectSourceSignature = this.#sourceIndex.getSignature(); const dependencySignatures = []; @@ -288,8 +324,11 @@ export default class ProjectBuildCache { * 3. Attempts to find a cached stage for the task * 4. Returns whether the task needs to be executed * - * @param {string} taskName - Name of the task to prepare - * @returns {Promise} True or object if task can use cache, false otherwise + * @public + * @param {string} taskName Name of the task to prepare + * @returns {Promise} + * True if task can use cache, false if task needs execution, + * or an object with cache information for differential updates */ async prepareTaskExecutionAndValidateCache(taskName) { const stageName = this.#getStageNameForTask(taskName); @@ -410,10 +449,10 @@ export default class ProjectBuildCache { * Checks both in-memory stage cache and persistent cache storage for a matching * stage signature. Returns the first matching cached stage found. * - * @private - * @param {string} stageName - Name of the stage to find - * @param {string[]} stageSignatures - Possible signatures for the stage - * @returns {Promise} Cached stage entry or null if not found + * @param {string} stageName Name of the stage to find + * @param {string[]} stageSignatures Possible signatures for the stage + * @returns {Promise<@ui5/project/build/cache/ProjectBuildCache~StageCacheEntry|undefined>} + * Cached stage entry or undefined if not found */ async #findStageCache(stageName, stageSignatures) { if (!stageSignatures.length) { @@ -485,13 +524,14 @@ export default class ProjectBuildCache { * 3. Invalidates downstream tasks if they depend on written resources * 4. Removes the task from the invalidated tasks list * - * @param {string} taskName - Name of the executed task + * @public + * @param {string} taskName Name of the executed task * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} projectResourceRequests - * Resource requests for project resources + * Resource requests for project resources * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests|undefined} dependencyResourceRequests - * Resource requests for dependency resources - * @param {object} cacheInfo - * @param {boolean} supportsDifferentialUpdates - Whether the task supports differential updates + * Resource requests for dependency resources + * @param {object} cacheInfo Cache information for differential updates + * @param {boolean} supportsDifferentialUpdates Whether the task supports differential updates * @returns {Promise} */ async recordTaskResult( @@ -568,8 +608,10 @@ export default class ProjectBuildCache { /** * Returns the task cache for a specific task * - * @param {string} taskName - Name of the task - * @returns {BuildTaskCache|undefined} The task cache or undefined if not found + * @public + * @param {string} taskName Name of the task + * @returns {@ui5/project/build/cache/BuildTaskCache|undefined} + * The task cache or undefined if not found */ getTaskCache(taskName) { return this.#taskCache.get(taskName); @@ -578,7 +620,8 @@ export default class ProjectBuildCache { /** * Records changed source files of the project and marks cache as stale * - * @param {string[]} changedPaths - Changed project source file paths + * @public + * @param {string[]} changedPaths Changed project source file paths */ projectSourcesChanged(changedPaths) { for (const resourcePath of changedPaths) { @@ -595,7 +638,8 @@ export default class ProjectBuildCache { /** * Records changed dependency resources and marks cache as stale * - * @param {string[]} changedPaths - Changed dependency resource paths + * @public + * @param {string[]} changedPaths Changed dependency resource paths */ dependencyResourcesChanged(changedPaths) { for (const resourcePath of changedPaths) { @@ -615,7 +659,8 @@ export default class ProjectBuildCache { * Creates stage names for each task and initializes them in the project. * This must be called before task execution begins. * - * @param {string[]} taskNames - Array of task names to initialize stages for + * @public + * @param {string[]} taskNames Array of task names to initialize stages for * @returns {Promise} */ async setTasks(taskNames) { @@ -632,7 +677,8 @@ export default class ProjectBuildCache { * final result stage containing all build outputs. * Also updates the result resource index accordingly. * - * @returns {Promise} Resolves with list of changed resources since the last build + * @public + * @returns {Promise} Array of changed resource paths since the last build */ async allTasksCompleted() { this.#project.useResultStage(); @@ -649,8 +695,7 @@ export default class ProjectBuildCache { /** * Generates the stage name for a given task * - * @private - * @param {string} taskName - Name of the task + * @param {string} taskName Name of the task * @returns {string} Stage name in the format "task/{taskName}" */ #getStageNameForTask(taskName) { @@ -664,7 +709,7 @@ export default class ProjectBuildCache { * the index against current source files and invalidates affected tasks if * resources have changed. If no cache exists, creates a fresh index. * - * @private + * @returns {Promise} * @throws {Error} If cached index signature doesn't match computed signature */ async #initSourceIndex() { @@ -719,6 +764,12 @@ export default class ProjectBuildCache { } } + /** + * Updates the source index with changed resource paths + * + * @param {string[]} changedResourcePaths Array of changed resource paths + * @returns {Promise} True if index was updated + */ async #updateSourceIndex(changedResourcePaths) { const sourceReader = this.#project.getSourceReader(); @@ -755,13 +806,14 @@ export default class ProjectBuildCache { * Stores all cache data to persistent storage * * This method: - * 2. Stores the result stage with all resources - * 3. Writes the resource index and task metadata - * 4. Stores all stage caches from the queue + * 1. Stores the result stage with all resources + * 2. Writes the resource index and task metadata + * 3. Stores all stage caches from the queue * - * @param {object} buildManifest - Build manifest containing metadata about the build - * @param {string} buildManifest.manifestVersion - Version of the manifest format - * @param {string} buildManifest.signature - Build signature + * @public + * @param {object} buildManifest Build manifest containing metadata about the build + * @param {string} buildManifest.manifestVersion Version of the manifest format + * @param {string} buildManifest.signature Build signature * @returns {Promise} */ async writeCache(buildManifest) { @@ -803,6 +855,11 @@ export default class ProjectBuildCache { this.#project.getId(), this.#buildSignature, stageSignature, metadata); } + /** + * Writes all pending task stage caches to persistent storage + * + * @returns {Promise} + */ async #writeTaskStageCaches() { if (!this.#stageCache.hasPendingCacheQueue()) { return; @@ -845,6 +902,14 @@ export default class ProjectBuildCache { })); } + /** + * Writes stage resources to persistent storage and returns their metadata + * + * @param {@ui5/fs/Resource[]} resources Array of resources to write + * @param {string} stageId Stage identifier + * @param {string} stageSignature Stage signature + * @returns {Promise>} Resource metadata indexed by path + */ async #writeStageResources(resources, stageId, stageSignature) { const resourceMetadata = Object.create(null); await Promise.all(resources.map(async (res) => { @@ -861,6 +926,11 @@ export default class ProjectBuildCache { return resourceMetadata; } + /** + * Writes task metadata caches to persistent storage + * + * @returns {Promise} + */ async #writeTaskMetadataCaches() { // Store task caches for (const [taskName, taskCache] of this.#taskCache) { @@ -882,6 +952,11 @@ export default class ProjectBuildCache { } } + /** + * Writes the source index cache to persistent storage + * + * @returns {Promise} + */ async #writeSourceIndex() { if (this.#cachedSourceSignature === this.#sourceIndex.getSignature()) { // No changes to already cached result index @@ -906,11 +981,10 @@ export default class ProjectBuildCache { * The reader provides virtual access to cached resources by loading them from * the cache storage on demand. Resource metadata is used to validate cache entries. * - * @private - * @param {string} stageId - Identifier for the stage (e.g., "result" or "task/{taskName}") - * @param {string} stageSignature - Signature hash of the stage - * @param {Object} resourceMetadata - Metadata for all cached resources - * @returns {Promise<@ui5/fs/AbstractReader>} Proxy reader for cached resources + * @param {string} stageId Identifier for the stage (e.g., "result" or "task/{taskName}") + * @param {string} stageSignature Signature hash of the stage + * @param {Object} resourceMetadata Metadata for all cached resources + * @returns {@ui5/fs/AbstractReader} Proxy reader for cached resources */ #createReaderForStageCache(stageId, stageSignature, resourceMetadata) { const allResourcePaths = Object.keys(resourceMetadata); @@ -961,6 +1035,12 @@ export default class ProjectBuildCache { } } +/** + * Computes the cartesian product of an array of arrays + * + * @param {Array} arrays Array of arrays to compute the product of + * @returns {Array} Array of all possible combinations + */ function cartesianProduct(arrays) { if (arrays.length === 0) return [[]]; if (arrays.some((arr) => arr.length === 0)) return []; @@ -980,6 +1060,16 @@ function cartesianProduct(arrays) { return result; } +/** + * Fast combination of two arrays into pairs + * + * Creates all possible pairs by combining each element from the first array + * with each element from the second array. + * + * @param {Array} array1 First array + * @param {Array} array2 Second array + * @returns {Array} Array of two-element pairs + */ function combineTwoArraysFast(array1, array2) { const len1 = array1.length; const len2 = array2.length; @@ -995,10 +1085,23 @@ function combineTwoArraysFast(array1, array2) { return result; } +/** + * Creates a combined stage signature from project and dependency signatures + * + * @param {string} projectSignature Project resource signature + * @param {string} dependencySignature Dependency resource signature + * @returns {string} Combined stage signature in format "projectSignature-dependencySignature" + */ function createStageSignature(projectSignature, dependencySignature) { return `${projectSignature}-${dependencySignature}`; } +/** + * Creates a combined signature hash from multiple stage dependency signatures + * + * @param {string[]} stageDependencySignatures Array of dependency signatures to combine + * @returns {string} SHA-256 hash of the combined signatures + */ function createDependencySignature(stageDependencySignatures) { return crypto.createHash("sha256").update(stageDependencySignatures.join("")).digest("hex"); } diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 19e3eb64a41..587218e65a6 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -5,6 +5,15 @@ import TreeRegistry from "./index/TreeRegistry.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:cache:ResourceRequestManager"); +/** + * Manages resource requests and their associated indices for a single task + * + * Tracks all resources accessed by a task during execution and maintains resource indices + * for cache validation and differential updates. Supports both full and delta-based caching + * strategies. + * + * @class + */ class ResourceRequestManager { #taskName; #projectName; @@ -17,6 +26,15 @@ class ResourceRequestManager { #useDifferentialUpdate; #unusedAtLeastOnce; + /** + * Creates a new ResourceRequestManager instance + * + * @param {string} projectName Name of the project + * @param {string} taskName Name of the task + * @param {boolean} useDifferentialUpdate Whether to track differential updates + * @param {ResourceRequestGraph} [requestGraph] Optional pre-existing request graph from cache + * @param {boolean} [unusedAtLeastOnce=false] Whether the task has been unused at least once + */ constructor(projectName, taskName, useDifferentialUpdate, requestGraph, unusedAtLeastOnce = false) { this.#projectName = projectName; this.#taskName = taskName; @@ -31,6 +49,22 @@ class ResourceRequestManager { } } + /** + * Factory method to restore a ResourceRequestManager from cached data + * + * Deserializes a previously cached request graph and its associated resource indices, + * including both root indices and delta indices for differential updates. + * + * @param {string} projectName Name of the project + * @param {string} taskName Name of the task + * @param {boolean} useDifferentialUpdate Whether to track differential updates + * @param {object} cacheData Cached metadata object + * @param {object} cacheData.requestSetGraph Serialized request graph + * @param {Array} cacheData.rootIndices Array of root resource indices + * @param {Array} [cacheData.deltaIndices] Array of delta resource indices + * @param {boolean} [cacheData.unusedAtLeastOnce] Whether the task has been unused + * @returns {ResourceRequestManager} Restored manager instance + */ static fromCache(projectName, taskName, useDifferentialUpdate, { requestSetGraph, rootIndices, deltaIndices, unusedAtLeastOnce }) { @@ -70,9 +104,10 @@ class ResourceRequestManager { * * Returns signatures from all recorded project-request sets. Each signature represents * a unique combination of resources belonging to the current project that were accessed - * during task execution. This can be used to form a cache keys for restoring cached task results. + * during task execution. This can be used to form cache keys for restoring cached task results. * - * @returns {Promise} Array of signature strings + * @public + * @returns {string[]} Array of signature strings * @throws {Error} If resource index is missing for any request set */ getIndexSignatures() { @@ -91,9 +126,15 @@ class ResourceRequestManager { } /** - * Update all indices based on current resources (no delta update) + * Updates all indices based on current resources without delta tracking * - * @param {module:@ui5/fs.AbstractReader} reader - Reader for accessing project resources + * Performs a full refresh of all resource indices by fetching current resources + * and updating or removing indexed resources as needed. Does not track changes + * between the old and new state. + * + * @public + * @param {module:@ui5/fs.AbstractReader} reader Reader for accessing project resources + * @returns {Promise} True if any changes were detected, false otherwise */ async refreshIndices(reader) { if (this.#requestGraph.getSize() === 0) { @@ -126,10 +167,15 @@ class ResourceRequestManager { } /** - * Filter relevant resource changes and update the indices if necessary + * Filters relevant resource changes and updates the indices if necessary * - * @param {module:@ui5/fs.AbstractReader} reader - Reader for accessing project resources - * @param {string[]} changedResourcePaths - Array of changed project resource path + * Processes changed resource paths, identifies which request sets are affected, + * and updates their resource indices accordingly. Supports both full updates and + * differential tracking based on the manager configuration. + * + * @public + * @param {module:@ui5/fs.AbstractReader} reader Reader for accessing project resources + * @param {string[]} changedResourcePaths Array of changed project resource paths * @returns {Promise} True if any changes were detected, false otherwise */ async updateIndices(reader, changedResourcePaths) { @@ -211,7 +257,6 @@ class ResourceRequestManager { * Tests each request against the changed resource paths using exact path matching * for 'path'/'dep-path' requests and glob pattern matching for 'patterns'/'dep-patterns' requests. * - * @private * @param {Request[]} resourceRequests - Array of resource requests to match against * @param {string[]} resourcePaths - Changed project resource paths * @returns {string[]} Array of matched resource paths @@ -231,7 +276,10 @@ class ResourceRequestManager { } /** - * Flushes all tree registries to apply batched updates, ignoring how trees changed + * Flushes all tree registries to apply batched updates without tracking changes + * + * Commits all pending tree modifications but does not record the specific changes + * (added, updated, removed resources). Used when differential updates are disabled. * * @returns {Promise} True if any changes were detected, false otherwise */ @@ -248,7 +296,11 @@ class ResourceRequestManager { } /** - * Flushes all tree registries to apply batched updates, keeping track of how trees changed + * Flushes all tree registries to apply batched updates while tracking changes + * + * Commits all pending tree modifications and records detailed information about + * which resources were added, updated, or removed. Used when differential updates + * are enabled to support incremental cache invalidation. * * @returns {Promise} True if any changes were detected, false otherwise */ @@ -285,12 +337,24 @@ class ResourceRequestManager { * Commits all pending tree modifications across all registries in parallel. * Must be called after operations that schedule updates via registries. * - * @returns {Promise} Object containing sets of added, updated, and removed resource paths + * @returns {Promise>} Array of flush results from all registries, + * each containing added, updated, unchanged, and removed resource paths */ async #flushTreeChanges() { return await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); } + /** + * Adds or updates a delta entry for tracking resource index changes + * + * Records the transition from an original signature to a new signature along with + * the specific resources that changed. Accumulates changes across multiple updates. + * + * @param {string} requestSetId Identifier of the request set + * @param {string} originalSignature Original resource index signature + * @param {string} newSignature New resource index signature + * @param {object} diff Object containing arrays of added, updated, unchanged, and removed resource paths + */ #addDeltaEntry(requestSetId, originalSignature, newSignature, diff) { if (!this.#treeUpdateDeltas.has(requestSetId)) { this.#treeUpdateDeltas.set(requestSetId, { @@ -330,6 +394,17 @@ class ResourceRequestManager { } } + /** + * Gets all delta entries for differential cache updates + * + * Returns a map of signature transitions and their associated changed resource paths. + * Only includes deltas where no resources were removed, as removed resources prevent + * differential updates. + * + * @public + * @returns {Map} Map from original signature to delta information + * containing newSignature and changedPaths array + */ getDeltas() { const deltas = new Map(); for (const {originalSignature, newSignature, diff} of this.#treeUpdateDeltas.values()) { @@ -353,10 +428,17 @@ class ResourceRequestManager { } /** + * Adds a new set of resource requests and returns their signature + * + * Processes recorded resource requests (both path and pattern-based), creates or reuses + * a request set in the graph, and returns the resulting resource index signature. * - * @param {ResourceRequests} requestRecording - Project resource requests (paths and patterns) - * @param {module:@ui5/fs.AbstractReader} reader - Reader for accessing project resources - * @returns {Promise} Signature hash string of the resource index + * @public + * @param {object} requestRecording Project resource requests + * @param {string[]} requestRecording.paths Array of requested resource paths + * @param {Array} requestRecording.patterns Array of glob pattern arrays + * @param {module:@ui5/fs.AbstractReader} reader Reader for accessing project resources + * @returns {Promise} Object containing setId and signature of the resource index */ async addRequests(requestRecording, reader) { const projectRequests = []; @@ -369,11 +451,31 @@ class ResourceRequestManager { return await this.#addRequestSet(projectRequests, reader); } + /** + * Records that a task made no resource requests + * + * Marks the manager as having been unused at least once and returns a special + * signature indicating no requests were made. + * + * @public + * @returns {string} Special signature "X" indicating no requests + */ recordNoRequests() { this.#unusedAtLeastOnce = true; return "X"; // Signature for when no requests were made } + /** + * Adds a request set and creates or reuses a resource index + * + * Attempts to find an existing matching request set to reuse. If not found, creates + * a new request set with either a derived or fresh resource index based on whether + * a parent request set exists. + * + * @param {Request[]} requests Array of resource requests + * @param {module:@ui5/fs.AbstractReader} reader Reader for accessing project resources + * @returns {Promise} Object containing setId and signature of the resource index + */ async #addRequestSet(requests, reader) { // Try to find an existing request set that we can reuse let setId = this.#requestGraph.findExactMatch(requests); @@ -416,6 +518,14 @@ class ResourceRequestManager { }; } + /** + * Associates a request set from this manager with one from another manager + * + * @public + * @param {string} ourRequestSetId Request set ID from this manager + * @param {string} foreignRequestSetId Request set ID from another manager + * @todo Implementation pending + */ addAffiliatedRequestSet(ourRequestSetId, foreignRequestSetId) { // TODO } @@ -441,7 +551,6 @@ class ResourceRequestManager { * - 'path': Retrieves single resource by path from the given reader * - 'patterns': Retrieves resources matching glob patterns from the given reader * - * @private * @param {Request[]|Array<{type: string, value: string|string[]}>} resourceRequests - Resource requests to process * @param {module:@ui5/fs.AbstractReader} reader - Resource reader * @param {Map} [resourceCache] @@ -473,17 +582,32 @@ class ResourceRequestManager { return Array.from(resourcesMap.values()); } + /** + * Checks whether new or modified cache entries exist + * + * Returns false if the manager was restored from cache and no modifications were made. + * Returns true if this is a new manager or if new request sets have been added. + * + * @public + * @returns {boolean} True if cache entries need to be written + */ hasNewOrModifiedCacheEntries() { return this.#hasNewOrModifiedCacheEntries; } /** - * Serializes the task cache to a plain object for persistence + * Serializes the manager to a plain object for persistence * - * Exports the resource request graph in a format suitable for JSON serialization. - * The serialized data can be passed to the constructor to restore the cache state. + * Exports the resource request graph and all resource indices in a format suitable + * for JSON serialization. The serialized data can be passed to fromCache() to restore + * the manager state. Returns undefined if no new or modified cache entries exist. * - * @returns {TaskCacheMetadata} Serialized cache metadata containing the request set graph + * @public + * @returns {object|undefined} Serialized cache metadata or undefined if no changes + * @returns {object} return.requestSetGraph Serialized request graph + * @returns {Array} return.rootIndices Array of root resource indices with node IDs + * @returns {Array} return.deltaIndices Array of delta resource indices with node IDs + * @returns {boolean} return.unusedAtLeastOnce Whether the task has been unused */ toCacheObject() { if (!this.#hasNewOrModifiedCacheEntries) { From f75bebe8e67fb741f4d7630d6724dee11472ba0f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 15:09:05 +0100 Subject: [PATCH 100/223] refactor(project): ProjectBuilder cleanup Add UI5_BUILD_NO_WRITE_DEST param, rename UI5_BUILD_NO_WRITE_CACHE --- packages/project/lib/build/ProjectBuilder.js | 24 +++---------------- .../lib/build/cache/ResourceRequestManager.js | 2 +- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 48fecb77e57..79f43399048 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -14,7 +14,6 @@ import OutputStyleEnum from "./helpers/ProjectBuilderOutputStyle.js"; class ProjectBuilder { #log; #buildIsRunning = false; - // #resourceChanges = new Map(); /** * Build Configuration @@ -122,26 +121,9 @@ class ProjectBuilder { } resourcesChanged(changes) { - // if (!this.#resourceChanges.size) { - // this.#resourceChanges = changes; - // return; - // } - // for (const [project, resourcePaths] of changes.entries()) { - // if (!this.#resourceChanges.has(project.getName())) { - // this.#resourceChanges.set(project.getName(), []); - // } - // const projectChanges = this.#resourceChanges.get(project.getName()); - // projectChanges.push(...resourcePaths); - // } - return this._buildContext.propagateResourceChanges(changes); } - // _flushResourceChanges() { - // this._buildContext.propagateResurceChanges(this.#resourceChanges); - // this.#resourceChanges = new Map(); - // } - async build({ includedDependencies = [], excludedDependencies = [], }) { @@ -188,7 +170,7 @@ class ProjectBuilder { const requestedProjects = this._determineRequestedProjects( includedDependencies, excludedDependencies, dependencyIncludes); - if (destPath && cleanDest) { + if (cleanDest) { this.#log.info(`Cleaning target directory...`); await rmrf(destPath); } @@ -288,12 +270,12 @@ class ProjectBuilder { } } - if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_CACHE_UPDATE) { + if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_CACHE) { this.#log.verbose(`Triggering cache update for project ${projectName}...`); pWrites.push(projectBuildContext.getBuildCache().writeCache()); } - if (fsTarget && requestedProjects.includes(projectName)) { + if (fsTarget && requestedProjects.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_DEST) { // Only write requested projects to target // (excluding dependencies that were required to be built, but not requested) this.#log.verbose(`Writing out files for project ${projectName}...`); diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 587218e65a6..49bef2fd76f 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -87,7 +87,7 @@ class ResourceRequestManager { const registry = registries.get(node.getParentId()); if (!registry) { throw new Error(`Missing tree registry for parent of node ID ${nodeId} of task ` + - `'${this.#taskName}' of project '${this.#projectName}'`); + `'${taskName}' of project '${projectName}'`); } const resourceIndex = parentResourceIndex.deriveTreeWithIndex(addedResourceIndex); From 65488503b779cd7e0a09c3a9e26f90a3c7aef1ea Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 15:11:06 +0100 Subject: [PATCH 101/223] refactor(builder): Small stringReplacer cleanup --- packages/builder/lib/processors/stringReplacer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/lib/processors/stringReplacer.js b/packages/builder/lib/processors/stringReplacer.js index 5002d426239..8c4bf6560dc 100644 --- a/packages/builder/lib/processors/stringReplacer.js +++ b/packages/builder/lib/processors/stringReplacer.js @@ -21,10 +21,10 @@ export default function({resources, options: {pattern, replacement}}) { return Promise.all(resources.map(async (resource) => { const content = await resource.getString(); const newContent = content.replaceAll(pattern, replacement); + // only modify the resource's string if it was changed if (content !== newContent) { resource.setString(newContent); return resource; } - // return resource; })); } From 637e6ff3afe96071877f82ffee0adb370c9e2c30 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 15:11:13 +0100 Subject: [PATCH 102/223] revert(fs): Add Switch reader This reverts commit 1db48f9a67236c59b47c8eeada80b0b229b59213. --- packages/fs/lib/readers/Switch.js | 82 ------------------------------ packages/fs/lib/resourceFactory.js | 14 ----- 2 files changed, 96 deletions(-) delete mode 100644 packages/fs/lib/readers/Switch.js diff --git a/packages/fs/lib/readers/Switch.js b/packages/fs/lib/readers/Switch.js deleted file mode 100644 index ed2bf2cca83..00000000000 --- a/packages/fs/lib/readers/Switch.js +++ /dev/null @@ -1,82 +0,0 @@ -import AbstractReader from "../AbstractReader.js"; - -/** - * Reader allowing to switch its underlying reader at runtime. - * If no reader is set, read operations will be halted/paused until a reader is set. - */ -export default class Switch extends AbstractReader { - #reader; - #pendingCalls = []; - - constructor({name, reader}) { - super(name); - this.#reader = reader; - } - - /** - * Sets the underlying reader and processes any pending read operations. - * - * @param {@ui5/fs/AbstractReader} reader The reader to delegate to. - */ - setReader(reader) { - this.#reader = reader; - this._processPendingCalls(); - } - - /** - * Unsets the underlying reader. Future calls will be queued. - */ - unsetReader() { - this.#reader = null; - } - - async _byGlob(virPattern, options, trace) { - if (this.#reader) { - return this.#reader._byGlob(virPattern, options, trace); - } - - // No reader set, so we queue the call and return a pending promise - return this._enqueueCall("_byGlob", [virPattern, options, trace]); - } - - - async _byPath(virPath, options, trace) { - if (this.#reader) { - return this.#reader._byPath(virPath, options, trace); - } - - // No reader set, so we queue the call and return a pending promise - return this._enqueueCall("_byPath", [virPath, options, trace]); - } - - /** - * Queues a method call by returning a promise and storing its resolver. - * - * @param {string} methodName The method name to call later. - * @param {Array} args The arguments to pass to the method. - * @returns {Promise} A promise that will be resolved/rejected when the call is processed. - */ - _enqueueCall(methodName, args) { - return new Promise((resolve, reject) => { - this.#pendingCalls.push({methodName, args, resolve, reject}); - }); - } - - /** - * Processes all pending calls in the queue using the current reader. - * - * @private - */ - _processPendingCalls() { - const callsToProcess = this.#pendingCalls; - this.#pendingCalls = []; // Clear queue immediately to prevent race conditions - - for (const call of callsToProcess) { - const {methodName, args, resolve, reject} = call; - // Execute the pending call with the newly set reader - this.#reader[methodName](...args) - .then(resolve) - .catch(reject); - } - } -} diff --git a/packages/fs/lib/resourceFactory.js b/packages/fs/lib/resourceFactory.js index cbd7227e62d..cfa27fd7bc5 100644 --- a/packages/fs/lib/resourceFactory.js +++ b/packages/fs/lib/resourceFactory.js @@ -10,7 +10,6 @@ import WriterCollection from "./WriterCollection.js"; import Filter from "./readers/Filter.js"; import Link from "./readers/Link.js"; import Proxy from "./readers/Proxy.js"; -import Switch from "./readers/Switch.js"; import MonitoredReader from "./MonitoredReader.js"; import MonitoredReaderWriter from "./MonitoredReaderWriter.js"; import {getLogger} from "@ui5/logger"; @@ -279,19 +278,6 @@ export function createFlatReader({name, reader, namespace}) { }); } -export function createSwitch({name, reader}) { - return new Switch({ - name, - reader: reader, - }); -} - -/** - * Creates a monitored reader or reader-writer depending on the provided instance - * of the given readerWriter. - * - * @param {@ui5/fs/AbstractReader|@ui5/fs/AbstractReaderWriter} readerWriter Reader or ReaderWriter to monitor - */ export function createMonitor(readerWriter) { if (readerWriter instanceof DuplexCollection) { return new MonitoredReaderWriter(readerWriter); From 3350ca195e91c2d2523d76b1ecea4daa88d8ce67 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 15:31:32 +0100 Subject: [PATCH 103/223] refactor(project): Add perf logging, cleanups --- packages/project/lib/build/BuildReader.js | 58 ++++- packages/project/lib/build/BuildServer.js | 127 ++++++++++ .../project/lib/build/cache/BuildTaskCache.js | 167 ++++++++++--- .../project/lib/build/cache/CacheManager.js | 168 +++++++------ .../lib/build/cache/ProjectBuildCache.js | 39 ++- .../lib/build/cache/ResourceRequestGraph.js | 236 +++++++++++++----- .../project/lib/build/cache/StageCache.js | 25 +- packages/project/lib/build/cache/utils.js | 35 +-- .../project/lib/build/helpers/BuildContext.js | 1 - .../lib/build/helpers/ProjectBuildContext.js | 164 ++++++++++-- .../project/test/lib/graph/ProjectGraph.js | 4 + 11 files changed, 789 insertions(+), 235 deletions(-) diff --git a/packages/project/lib/build/BuildReader.js b/packages/project/lib/build/BuildReader.js index f420ad2a171..5118fb7bbb0 100644 --- a/packages/project/lib/build/BuildReader.js +++ b/packages/project/lib/build/BuildReader.js @@ -1,5 +1,15 @@ import AbstractReader from "@ui5/fs/AbstractReader"; +/** + * Reader for accessing build results of multiple projects + * + * Provides efficient resource access by delegating to appropriate project readers + * based on resource paths and namespaces. Supports namespace-based routing to + * minimize unnecessary project searches. + * + * @class + * @extends @ui5/fs/AbstractReader + */ class BuildReader extends AbstractReader { #projects; #projectNames; @@ -7,6 +17,16 @@ class BuildReader extends AbstractReader { #getReaderForProject; #getReaderForProjects; + /** + * Creates a new BuildReader instance + * + * @public + * @param {string} name Name of the reader + * @param {Array<@ui5/project/specifications/Project>} projects Array of projects to read from + * @param {Function} getReaderForProject Function that returns a reader for a single project by name + * @param {Function} getReaderForProjects Function that returns a combined reader for multiple project names + * @throws {Error} If multiple projects share the same namespace + */ constructor(name, projects, getReaderForProject, getReaderForProjects) { super(name); this.#projects = projects; @@ -27,11 +47,31 @@ class BuildReader extends AbstractReader { } } + /** + * Locates resources by glob pattern + * + * Retrieves a combined reader for all projects and delegates the glob search to it. + * + * @public + * @param {...*} args Arguments to pass to the underlying reader's byGlob method + * @returns {Promise>} Promise resolving to list of resources + */ async byGlob(...args) { const reader = await this.#getReaderForProjects(this.#projectNames); return reader.byGlob(...args); } + /** + * Locates a resource by path + * + * Attempts to determine the appropriate project reader based on the resource path + * and namespace. Falls back to searching all projects if the resource cannot be found. + * + * @public + * @param {string} virPath Virtual path of the resource + * @param {...*} args Additional arguments to pass to the underlying reader's byPath method + * @returns {Promise<@ui5/fs/Resource|null>} Promise resolving to resource or null if not found + */ async byPath(virPath, ...args) { const reader = await this._getReaderForResource(virPath); let res = await reader.byPath(virPath, ...args); @@ -43,7 +83,16 @@ class BuildReader extends AbstractReader { return res; } - + /** + * Gets the appropriate reader for a resource at the given path + * + * Determines which project(s) might contain the resource based on namespace matching + * and returns a reader for those projects. For single-project readers, returns that + * project's reader directly. + * + * @param {string} virPath Virtual path of the resource + * @returns {Promise<@ui5/fs/AbstractReader>} Promise resolving to appropriate reader + */ async _getReaderForResource(virPath) { let reader; if (this.#projects.length === 1) { @@ -65,9 +114,14 @@ class BuildReader extends AbstractReader { } /** - * Determine which projects might contain the resource for the given path. + * Determines which projects might contain the resource for the given path + * + * Analyzes the resource path to identify matching project namespaces. Only processes + * paths starting with /resources/ or /test-resources/. Returns project names in order + * from most specific to least specific namespace match. * * @param {string} virPath Virtual resource path + * @returns {string[]} Array of project names that might contain the resource */ _getProjectsForResourcePath(virPath) { if (!virPath.startsWith("/resources/") && !virPath.startsWith("/test-resources/")) { diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index d1e720342f9..0d3bc17e6e0 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -3,6 +3,27 @@ import {createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; import BuildReader from "./BuildReader.js"; import WatchHandler from "./helpers/WatchHandler.js"; +/** + * Development server that provides access to built project resources with automatic rebuilding + * + * BuildServer watches project sources for changes and automatically rebuilds affected projects + * on-demand. It provides readers for accessing built resources and emits events for build + * completion and source changes. + * + * The server maintains separate readers for: + * - All projects (root + dependencies) + * - Root project only + * - Dependencies only + * + * Projects are built lazily when their resources are first requested, and rebuilt automatically + * when source files change. + * + * @class + * @extends EventEmitter + * @fires BuildServer#buildFinished + * @fires BuildServer#sourcesChanged + * @fires BuildServer#error + */ class BuildServer extends EventEmitter { #graph; #projectBuilder; @@ -12,6 +33,18 @@ class BuildServer extends EventEmitter { #dependenciesReader; #projectReaders = new Map(); + /** + * Creates a new BuildServer instance + * + * Initializes readers for different project combinations, sets up file watching, + * and optionally performs an initial build of specified dependencies. + * + * @public + * @param {@ui5/project/graph/ProjectGraph} graph Project graph containing all projects + * @param {@ui5/project/build/ProjectBuilder} projectBuilder Builder instance for executing builds + * @param {string[]} initialBuildIncludedDependencies Project names to include in initial build + * @param {string[]} initialBuildExcludedDependencies Project names to exclude from initial build + */ constructor(graph, projectBuilder, initialBuildIncludedDependencies, initialBuildExcludedDependencies) { super(); this.#graph = graph; @@ -64,18 +97,57 @@ class BuildServer extends EventEmitter { }); } + /** + * Gets a reader for all projects (root and dependencies) + * + * Returns a reader that provides access to built resources from all projects in the graph. + * Projects are built on-demand when their resources are requested. + * + * @public + * @returns {BuildReader} Reader for all projects + */ getReader() { return this.#allReader; } + /** + * Gets a reader for the root project only + * + * Returns a reader that provides access to built resources from only the root project, + * excluding all dependencies. The root project is built on-demand when its resources + * are requested. + * + * @public + * @returns {BuildReader} Reader for root project + */ getRootReader() { return this.#rootReader; } + /** + * Gets a reader for dependencies only (excluding root project) + * + * Returns a reader that provides access to built resources from all transitive + * dependencies of the root project. Dependencies are built on-demand when their + * resources are requested. + * + * @public + * @returns {BuildReader} Reader for all dependencies + */ getDependenciesReader() { return this.#dependenciesReader; } + /** + * Gets a reader for a single project, building it if necessary + * + * Checks if the project has already been built and returns its reader from cache. + * If not built, waits for any in-progress build, then triggers a build for the + * requested project. + * + * @param {string} projectName Name of the project to get reader for + * @returns {Promise<@ui5/fs/AbstractReader>} Reader for the built project + */ async #getReaderForProject(projectName) { if (this.#projectReaders.has(projectName)) { return this.#projectReaders.get(projectName); @@ -101,6 +173,16 @@ class BuildServer extends EventEmitter { return this.#projectReaders.get(projectName); } + /** + * Gets a combined reader for multiple projects, building them if necessary + * + * Determines which projects need to be built, waits for any in-progress build, + * then triggers a build for any missing projects. Returns a prioritized collection + * reader combining all requested projects. + * + * @param {string[]} projectNames Array of project names to get readers for + * @returns {Promise<@ui5/fs/ReaderCollection>} Combined reader for all requested projects + */ async #getReaderForProjects(projectNames) { let projectsRequiringBuild = []; for (const projectName of projectNames) { @@ -140,6 +222,15 @@ class BuildServer extends EventEmitter { return this.#getReaderForCachedProjects(projectNames); } + /** + * Creates a combined reader for already-built projects + * + * Retrieves readers from the cache for the specified projects and combines them + * into a prioritized reader collection. + * + * @param {string[]} projectNames Array of project names to combine + * @returns {@ui5/fs/ReaderCollection} Combined reader for cached projects + */ #getReaderForCachedProjects(projectNames) { const readers = []; for (const projectName of projectNames) { @@ -181,6 +272,15 @@ class BuildServer extends EventEmitter { // return this.#allProjectsReader; // } + /** + * Handles completion of a project build + * + * Caches readers for all built projects and emits the buildFinished event + * with the list of project names that were built. + * + * @param {string[]} projectNames Array of project names that were built + * @fires BuildServer#buildFinished + */ #projectBuildFinished(projectNames) { for (const projectName of projectNames) { this.#projectReaders.set(projectName, @@ -190,5 +290,32 @@ class BuildServer extends EventEmitter { } } +/** + * Build finished event + * + * Emitted when one or more projects have finished building. + * + * @event BuildServer#buildFinished + * @param {string[]} projectNames Array of project names that were built + */ + +/** + * Sources changed event + * + * Emitted when source files have changed and affected projects have been invalidated. + * + * @event BuildServer#sourcesChanged + * @param {string[]} changedResourcePaths Array of changed resource paths + */ + +/** + * Error event + * + * Emitted when an error occurs during watching or building. + * + * @event BuildServer#error + * @param {Error} error The error that occurred + */ + export default BuildServer; diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index f9b8820800a..461e4aee16a 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -4,8 +4,8 @@ const log = getLogger("build:cache:BuildTaskCache"); /** * @typedef {object} @ui5/project/build/cache/BuildTaskCache~ResourceRequests - * @property {Set} paths - Specific resource paths that were accessed - * @property {Set} patterns - Glob patterns used to access resources + * @property {Set} paths Specific resource paths that were accessed + * @property {Set} patterns Glob patterns used to access resources */ /** @@ -24,6 +24,8 @@ const log = getLogger("build:cache:BuildTaskCache"); * * The request graph allows derived request sets (when a task reads additional resources) * to reuse existing resource indices, optimizing both memory and computation. + * + * @class */ export default class BuildTaskCache { #projectName; @@ -36,11 +38,13 @@ export default class BuildTaskCache { /** * Creates a new BuildTaskCache instance * - * @param {string} projectName - Name of the project this task belongs to - * @param {string} taskName - Name of the task this cache manages - * @param {boolean} supportsDifferentialUpdates - * @param {ResourceRequestManager} [projectRequestManager] + * @public + * @param {string} projectName Name of the project this task belongs to + * @param {string} taskName Name of the task this cache manages + * @param {boolean} supportsDifferentialUpdates Whether the task supports differential updates + * @param {ResourceRequestManager} [projectRequestManager] Optional pre-existing project request manager from cache * @param {ResourceRequestManager} [dependencyRequestManager] + * Optional pre-existing dependency request manager from cache */ constructor(projectName, taskName, supportsDifferentialUpdates, projectRequestManager, dependencyRequestManager) { this.#projectName = projectName; @@ -55,6 +59,20 @@ export default class BuildTaskCache { new ResourceRequestManager(projectName, taskName, supportsDifferentialUpdates); } + /** + * Factory method to restore a BuildTaskCache from cached data + * + * Deserializes previously cached request managers for both project and dependency resources, + * allowing the task cache to resume from a prior build state. + * + * @public + * @param {string} projectName Name of the project + * @param {string} taskName Name of the task + * @param {boolean} supportsDifferentialUpdates Whether the task supports differential updates + * @param {object} projectRequests Cached project request manager data + * @param {object} dependencyRequests Cached dependency request manager data + * @returns {BuildTaskCache} Restored task cache instance + */ static fromCache(projectName, taskName, supportsDifferentialUpdates, projectRequests, dependencyRequests) { const projectRequestManager = ResourceRequestManager.fromCache(projectName, taskName, supportsDifferentialUpdates, projectRequests); @@ -69,46 +87,83 @@ export default class BuildTaskCache { /** * Gets the name of the task * + * @public * @returns {string} Task name */ getTaskName() { return this.#taskName; } + /** + * Checks whether the task supports differential updates + * + * Tasks that support differential updates can use incremental cache invalidation, + * processing only changed resources rather than rebuilding from scratch. + * + * @public + * @returns {boolean} True if differential updates are supported + */ getSupportsDifferentialUpdates() { return this.#supportsDifferentialUpdates; } + /** + * Checks whether new or modified cache entries exist + * + * Returns true if either the project or dependency request managers have new or + * modified cache entries that need to be persisted. + * + * @public + * @returns {boolean} True if cache entries need to be written + */ hasNewOrModifiedCacheEntries() { return this.#projectRequestManager.hasNewOrModifiedCacheEntries() || this.#dependencyRequestManager.hasNewOrModifiedCacheEntries(); } /** - * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources - * @param {string[]} changedProjectResourcePaths - Array of changed project resource path - * @returns {Promise} Whether any index has changed + * Updates project resource indices based on changed resource paths + * + * Processes changed resource paths and updates the project request manager's indices + * accordingly. Only relevant resources (those matching recorded requests) are processed. + * + * @public + * @param {module:@ui5/fs.AbstractReader} projectReader Reader for accessing project resources + * @param {string[]} changedProjectResourcePaths Array of changed project resource paths + * @returns {Promise} True if any index has changed */ - async updateProjectIndices(projectReader, changedProjectResourcePaths) { - return await this.#projectRequestManager.updateIndices(projectReader, changedProjectResourcePaths); + updateProjectIndices(projectReader, changedProjectResourcePaths) { + return this.#projectRequestManager.updateIndices(projectReader, changedProjectResourcePaths); } /** + * Updates dependency resource indices based on changed resource paths * - * Special case for dependency indices: Since dependency resources may change independently from this - * projects cache, we need to update the full index once at the beginning of every build from cache. - * This is triggered by calling this method without changedDepResourcePaths. + * Processes changed dependency resource paths and updates the dependency request manager's + * indices accordingly. Only relevant resources (those matching recorded requests) are processed. * - * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for accessing dependency resources - * @param {string[]} [changedDepResourcePaths] - Array of changed dependency resource paths - * @returns {Promise} Whether any index has changed + * @public + * @param {module:@ui5/fs.AbstractReader} dependencyReader Reader for accessing dependency resources + * @param {string[]} changedDepResourcePaths Array of changed dependency resource paths + * @returns {Promise} True if any index has changed */ - async updateDependencyIndices(dependencyReader, changedDepResourcePaths) { - if (changedDepResourcePaths) { - return await this.#dependencyRequestManager.updateIndices(dependencyReader, changedDepResourcePaths); - } else { - return await this.#dependencyRequestManager.refreshIndices(dependencyReader); - } + updateDependencyIndices(dependencyReader, changedDepResourcePaths) { + return this.#dependencyRequestManager.updateIndices(dependencyReader, changedDepResourcePaths); + } + + /** + * Performs a full refresh of the dependency resource index + * + * Since dependency resources may change independently from this project's cache, a full + * refresh of the dependency index is required at the beginning of every build from cache. + * This ensures all dependency resources are current before task execution. + * + * @public + * @param {module:@ui5/fs.AbstractReader} dependencyReader Reader for accessing dependency resources + * @returns {Promise} True if any index has changed + */ + refreshDependencyIndices(dependencyReader) { + return this.#dependencyRequestManager.refreshIndices(dependencyReader); } /** @@ -116,8 +171,9 @@ export default class BuildTaskCache { * * Returns signatures from all recorded project-request sets. Each signature represents * a unique combination of resources, belonging to the current project, that were accessed - * during task execution. This can be used to form a cache keys for restoring cached task results. + * during task execution. These can be used as cache keys for restoring cached task results. * + * @public * @returns {string[]} Array of signature strings * @throws {Error} If resource index is missing for any request set */ @@ -129,9 +185,11 @@ export default class BuildTaskCache { * Gets all dependency index signatures for this task * * Returns signatures from all recorded dependency-request sets. Each signature represents - * a unique combination of resources, belonging to all dependencies of the current project, that were accessed - * during task execution. This can be used to form a cache keys for restoring cached task results. + * a unique combination of resources, belonging to all dependencies of the current project, + * that were accessed during task execution. These can be used as cache keys for restoring + * cached task results. * + * @public * @returns {string[]} Array of signature strings * @throws {Error} If resource index is missing for any request set */ @@ -139,32 +197,57 @@ export default class BuildTaskCache { return this.#dependencyRequestManager.getIndexSignatures(); } + /** + * Gets all project index delta transitions for differential updates + * + * Returns a map of signature transitions and their associated changed resource paths + * for project resources. Used when tasks support differential updates to identify + * which resources changed between cache states. + * + * @public + * @returns {Map} Map from original signature to delta information + * containing newSignature and changedPaths array + */ getProjectIndexDeltas() { return this.#projectRequestManager.getDeltas(); } + /** + * Gets all dependency index delta transitions for differential updates + * + * Returns a map of signature transitions and their associated changed resource paths + * for dependency resources. Used when tasks support differential updates to identify + * which dependency resources changed between cache states. + * + * @public + * @returns {Map} Map from original signature to delta information + * containing newSignature and changedPaths array + */ getDependencyIndexDeltas() { return this.#dependencyRequestManager.getDeltas(); } /** - * Calculates a signature for the task based on accessed resources + * Records resource requests and calculates signatures for the task * * This method: - * 1. Converts resource requests to Request objects - * 2. Searches for an exact match in the request graph - * 3. If found, returns the existing index signature - * 4. If not found, creates a new request set and resource index + * 1. Processes project and dependency resource requests + * 2. Searches for exact matches in the request graphs + * 3. If found, returns the existing index signatures + * 4. If not found, creates new request sets and resource indices * 5. Uses tree derivation when possible to reuse parent indices * - * The signature uniquely identifies the set of resources accessed and their + * The returned signatures uniquely identify the set of resources accessed and their * content, enabling cache lookup for previously executed task results. * - * @param {ResourceRequests} projectRequestRecording - Project resource requests (paths and patterns) - * @param {ResourceRequests|undefined} dependencyRequestRecording - Dependency resource requests - * @param {module:@ui5/fs.AbstractReader} projectReader - Reader for accessing project resources - * @param {module:@ui5/fs.AbstractReader} dependencyReader - Reader for accessing dependency resources - * @returns {Promise} Signature hash string of the resource index + * @public + * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests} projectRequestRecording + * Project resource requests (paths and patterns) + * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests|undefined} dependencyRequestRecording + * Dependency resource requests (paths and patterns) + * @param {module:@ui5/fs.AbstractReader} projectReader Reader for accessing project resources + * @param {module:@ui5/fs.AbstractReader} dependencyReader Reader for accessing dependency resources + * @returns {Promise} Array containing [projectSignature, dependencySignature] */ async recordRequests(projectRequestRecording, dependencyRequestRecording, projectReader, dependencyReader) { const { @@ -186,12 +269,14 @@ export default class BuildTaskCache { } /** - * Serializes the task cache to a plain object for persistence + * Serializes the task cache to plain objects for persistence * - * Exports the resource request graph in a format suitable for JSON serialization. - * The serialized data can be passed to the constructor to restore the cache state. + * Exports both project and dependency resource request graphs in a format suitable + * for JSON serialization. The serialized data can be passed to fromCache() to restore + * the cache state. Returns undefined for request managers with no new or modified entries. * - * @returns {object[]} Serialized cache metadata containing the request set graphs + * @public + * @returns {Array} Array containing [projectCacheObject, dependencyCacheObject] */ toCacheObjects() { return [this.#projectRequestManager.toCacheObject(), this.#dependencyRequestManager.toCacheObject()]; diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index a2c6e476aab..69c387a31d1 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -42,6 +42,8 @@ const CACHE_VERSION = "v0_1"; * - Singleton pattern per cache directory * - Configurable cache location via UI5_DATA_DIR or configuration * - Efficient resource deduplication through cacache + * + * @class */ export default class CacheManager { #casDir; @@ -58,7 +60,7 @@ export default class CacheManager { * use CacheManager.create() instead to get a singleton instance. * * @private - * @param {string} cacheDir - Base directory for the cache + * @param {string} cacheDir Base directory for the cache */ constructor(cacheDir) { cacheDir = path.join(cacheDir, CACHE_VERSION); @@ -79,7 +81,8 @@ export default class CacheManager { * 2. ui5DataDir from UI5 configuration file * 3. Default: ~/.ui5/ * - * @param {string} cwd - Current working directory for resolving relative paths + * @public + * @param {string} cwd Current working directory for resolving relative paths * @returns {Promise} Singleton CacheManager instance for the cache directory */ static async create(cwd) { @@ -106,9 +109,8 @@ export default class CacheManager { /** * Generates the file path for a build manifest * - * @private - * @param {string} packageName - Package/project identifier - * @param {string} buildSignature - Build signature hash + * @param {string} packageName Package/project identifier + * @param {string} buildSignature Build signature hash * @returns {string} Absolute path to the build manifest file */ #getBuildManifestPath(packageName, buildSignature) { @@ -119,8 +121,9 @@ export default class CacheManager { /** * Reads a build manifest from cache * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash * @returns {Promise} Parsed manifest object or null if not found * @throws {Error} If file read fails for reasons other than file not existing */ @@ -146,9 +149,10 @@ export default class CacheManager { * Creates parent directories if they don't exist. Manifests are stored as * formatted JSON (2-space indentation) for readability. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {object} manifest - Build manifest object to serialize + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {object} manifest Build manifest object to serialize * @returns {Promise} */ async writeBuildManifest(projectId, buildSignature, manifest) { @@ -160,9 +164,8 @@ export default class CacheManager { /** * Generates the file path for resource index metadata * - * @private - * @param {string} packageName - Package/project identifier - * @param {string} buildSignature - Build signature hash + * @param {string} packageName Package/project identifier + * @param {string} buildSignature Build signature hash * @param {string} kind "source" or "result" * @returns {string} Absolute path to the index metadata file */ @@ -177,8 +180,9 @@ export default class CacheManager { * The index cache contains the resource tree structure and task metadata, * enabling efficient change detection and cache validation. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash * @param {string} kind "source" or "result" * @returns {Promise} Parsed index cache object or null if not found * @throws {Error} If file read fails for reasons other than file not existing @@ -205,10 +209,11 @@ export default class CacheManager { * Persists the resource index and associated task metadata for later retrieval. * Creates parent directories if needed. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash * @param {string} kind "source" or "result" - * @param {object} index - Index object containing resource tree and task metadata + * @param {object} index Index object containing resource tree and task metadata * @returns {Promise} */ async writeIndexCache(projectId, buildSignature, kind, index) { @@ -220,11 +225,10 @@ export default class CacheManager { /** * Generates the file path for stage metadata * - * @private - * @param {string} packageName - Package/project identifier - * @param {string} buildSignature - Build signature hash - * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature - Stage signature hash (based on input resources) + * @param {string} packageName Package/project identifier + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature Stage signature hash (based on input resources) * @returns {string} Absolute path to the stage metadata file */ #getStageMetadataPath(packageName, buildSignature, stageId, stageSignature) { @@ -239,10 +243,11 @@ export default class CacheManager { * Stage metadata contains information about resources produced by a build stage, * including resource paths and their metadata. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature - Stage signature hash (based on input resources) + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature Stage signature hash (based on input resources) * @returns {Promise} Parsed stage metadata or null if not found * @throws {Error} If file read fails for reasons other than file not existing */ @@ -270,11 +275,12 @@ export default class CacheManager { * Persists metadata about resources produced by a build stage. * Creates parent directories if needed. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature - Stage signature hash (based on input resources) - * @param {object} metadata - Stage metadata object to serialize + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature Stage signature hash (based on input resources) + * @param {object} metadata Stage metadata object to serialize * @returns {Promise} */ async writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata) { @@ -285,14 +291,13 @@ export default class CacheManager { } /** - * Generates the file path for stage metadata + * Generates the file path for task metadata * - * @private - * @param {string} packageName - Package/project identifier - * @param {string} buildSignature - Build signature hash - * @param {string} taskName - * @param {string} type - "project" or "dependency" - * @returns {string} Absolute path to the stage metadata file + * @param {string} packageName Package/project identifier + * @param {string} buildSignature Build signature hash + * @param {string} taskName Task name + * @param {string} type "project" or "dependency" + * @returns {string} Absolute path to the task metadata file */ #getTaskMetadataPath(packageName, buildSignature, taskName, type) { const pkgDir = getPathFromPackageName(packageName); @@ -300,16 +305,17 @@ export default class CacheManager { } /** - * Reads stage metadata from cache + * Reads task metadata from cache * - * Stage metadata contains information about resources produced by a build stage, - * including resource paths and their metadata. + * Task metadata contains resource request graphs and indices for tracking + * which resources a task accessed during execution. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {string} taskName - * @param {string} type - "project" or "dependency" - * @returns {Promise} Parsed stage metadata or null if not found + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {string} taskName Task name + * @param {string} type "project" or "dependency" + * @returns {Promise} Parsed task metadata or null if not found * @throws {Error} If file read fails for reasons other than file not existing */ async readTaskMetadata(projectId, buildSignature, taskName, type) { @@ -330,16 +336,17 @@ export default class CacheManager { } /** - * Writes stage metadata to cache + * Writes task metadata to cache * - * Persists metadata about resources produced by a build stage. + * Persists task-specific metadata including resource request graphs and indices. * Creates parent directories if needed. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {string} taskName - * @param {string} type - "project" or "dependency" - * @param {object} metadata - Stage metadata object to serialize + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {string} taskName Task name + * @param {string} type "project" or "dependency" + * @param {object} metadata Task metadata object to serialize * @returns {Promise} */ async writeTaskMetadata(projectId, buildSignature, taskName, type, metadata) { @@ -351,11 +358,10 @@ export default class CacheManager { /** * Generates the file path for result metadata * - * @private - * @param {string} packageName - Package/project identifier - * @param {string} buildSignature - Build signature hash - * @param {string} stageSignature - Stage signature hash (based on input resources) - * @returns {string} Absolute path to the stage metadata file + * @param {string} packageName Package/project identifier + * @param {string} buildSignature Build signature hash + * @param {string} stageSignature Stage signature hash (based on input resources) + * @returns {string} Absolute path to the result metadata file */ #getResultMetadataPath(packageName, buildSignature, stageSignature) { const pkgDir = getPathFromPackageName(packageName); @@ -365,13 +371,14 @@ export default class CacheManager { /** * Reads result metadata from cache * - * Stage metadata contains information about resources produced by a build stage, - * including resource paths and their metadata. + * Result metadata contains information about the final build output, including + * references to all stage signatures that comprise the result. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {string} stageSignature - Stage signature hash (based on input resources) - * @returns {Promise} Parsed stage metadata or null if not found + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {string} stageSignature Stage signature hash (based on input resources) + * @returns {Promise} Parsed result metadata or null if not found * @throws {Error} If file read fails for reasons other than file not existing */ async readResultMetadata(projectId, buildSignature, stageSignature) { @@ -395,13 +402,14 @@ export default class CacheManager { /** * Writes result metadata to cache * - * Persists metadata about resources produced by a build stage. + * Persists metadata about the final build result, including stage signature mappings. * Creates parent directories if needed. * - * @param {string} projectId - Project identifier (typically package name) - * @param {string} buildSignature - Build signature hash - * @param {string} stageSignature - Stage signature hash (based on input resources) - * @param {object} metadata - Stage metadata object to serialize + * @public + * @param {string} projectId Project identifier (typically package name) + * @param {string} buildSignature Build signature hash + * @param {string} stageSignature Stage signature hash (based on input resources) + * @param {object} metadata Result metadata object to serialize * @returns {Promise} */ async writeResultMetadata(projectId, buildSignature, stageSignature, metadata) { @@ -418,11 +426,12 @@ export default class CacheManager { * and verifies its integrity. If integrity mismatches, attempts to recover by * looking up the content by digest and updating the index. * - * @param {string} buildSignature - Build signature hash - * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature - Stage signature hash - * @param {string} resourcePath - Virtual path of the resource - * @param {string} integrity - Expected integrity hash (e.g., "sha256-...") + * @public + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature Stage signature hash + * @param {string} resourcePath Virtual path of the resource + * @param {string} integrity Expected integrity hash (e.g., "sha256-...") * @returns {Promise} Absolute path to the cached resource file, or null if not found * @throws {Error} If integrity is not provided */ @@ -448,10 +457,11 @@ export default class CacheManager { * This enables efficient deduplication when the same resource content appears * in multiple stages or builds. * - * @param {string} buildSignature - Build signature hash - * @param {string} stageId - Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature - Stage signature hash - * @param {module:@ui5/fs.Resource} resource - Resource to cache + * @public + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") + * @param {string} stageSignature Stage signature hash + * @param {@ui5/fs/Resource} resource Resource to cache * @returns {Promise} */ async writeStageResource(buildSignature, stageId, stageSignature, resource) { diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 16a9bb5725b..f71122b94da 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -85,7 +85,13 @@ export default class ProjectBuildCache { */ static async create(project, buildSignature, cacheManager) { const cache = new ProjectBuildCache(project, buildSignature, cacheManager); + const initStart = performance.now(); await cache.#initSourceIndex(); + if (log.isLevelEnabled("perf")) { + log.perf( + `Initialized source index for project ${project.getName()} ` + + `in ${(performance.now() - initStart).toFixed(2)} ms`); + } return cache; } @@ -111,10 +117,28 @@ export default class ProjectBuildCache { return false; } if (forceDependencyUpdate) { - await this.#updateDependencyIndices(dependencyReader); + const updateStart = performance.now(); + await this.#refreshDependencyIndices(dependencyReader); + if (log.isLevelEnabled("perf")) { + log.perf( + `Refreshed dependency indices for project ${this.#project.getName()} ` + + `in ${(performance.now() - updateStart).toFixed(2)} ms`); + } } + const flushStart = performance.now(); await this.#flushPendingChanges(); + if (log.isLevelEnabled("perf")) { + log.perf( + `Flushed pending changes for project ${this.#project.getName()} ` + + `in ${(performance.now() - flushStart).toFixed(2)} ms`); + } + const findStart = performance.now(); const changedResources = await this.#findResultCache(); + if (log.isLevelEnabled("perf")) { + log.perf( + `Validated result cache for project ${this.#project.getName()} ` + + `in ${(performance.now() - findStart).toFixed(2)} ms`); + } return changedResources; } @@ -158,15 +182,15 @@ export default class ProjectBuildCache { } /** - * Updates dependency indices for all tasks + * Refresh dependency indices for all tasks * * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources * @returns {Promise} */ - async #updateDependencyIndices(dependencyReader) { + async #refreshDependencyIndices(dependencyReader) { let depIndicesChanged = false; await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { - const changed = await taskCache.updateDependencyIndices(this.#currentDependencyReader); + const changed = await taskCache.refreshDependencyIndices(this.#currentDependencyReader); if (changed) { depIndicesChanged = true; } @@ -342,10 +366,15 @@ export default class ProjectBuildCache { log.verbose(`No task cache found`); return false; } - if (this.#writtenResultResourcePaths.length) { // Update task indices based on source changes and changes from by previous tasks + const updateProjectIndicesStart = performance.now(); await taskCache.updateProjectIndices(this.#currentProjectReader, this.#writtenResultResourcePaths); + if (log.isLevelEnabled("perf")) { + log.perf( + `Updated project indices for task ${taskName} in project ${this.#project.getName()} ` + + `in ${(performance.now() - updateProjectIndicesStart).toFixed(2)} ms`); + } } // TODO: Implement: diff --git a/packages/project/lib/build/cache/ResourceRequestGraph.js b/packages/project/lib/build/cache/ResourceRequestGraph.js index 099939a3cea..73655cace6b 100644 --- a/packages/project/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/lib/build/cache/ResourceRequestGraph.js @@ -1,12 +1,21 @@ const ALLOWED_REQUEST_TYPES = new Set(["path", "patterns"]); /** - * Represents a single request with type and value + * Represents a single resource request with type and value + * + * A request can be either a path-based request (single resource) or a pattern-based + * request (multiple resources via glob patterns). + * + * @class */ export class Request { /** - * @param {string} type - Either 'path' or 'pattern' - * @param {string|string[]} value - The request value (string for path types, array for pattern types) + * Creates a new Request instance + * + * @public + * @param {string} type Either 'path' or 'patterns' + * @param {string|string[]} value The request value (string for path, array for patterns) + * @throws {Error} If type is invalid or value type doesn't match request type */ constructor(type, value) { if (!ALLOWED_REQUEST_TYPES.has(type)) { @@ -23,8 +32,12 @@ export class Request { } /** - * Create a canonical string representation for comparison + * Creates a canonical string representation for comparison + * + * Converts the request to a unique key string that can be used for equality + * checks and set operations. * + * @public * @returns {string} Canonical key in format "type:value" or "type:[pattern1,pattern2,...]" */ toKey() { @@ -35,10 +48,13 @@ export class Request { } /** - * Create Request from key string + * Creates a Request instance from a key string * - * @param {string} key - Key in format "type:value" or "type:[...]" - * @returns {Request} Request instance + * Inverse operation of toKey(), reconstructing a Request from its string representation. + * + * @public + * @param {string} key Key in format "type:value" or "type:[...]" + * @returns {Request} Reconstructed Request instance */ static fromKey(key) { const colonIndex = key.indexOf(":"); @@ -55,9 +71,12 @@ export class Request { } /** - * Check equality with another Request + * Checks equality with another Request + * + * Compares both type and value, handling array values correctly. * - * @param {Request} other - Request to compare with + * @public + * @param {Request} other Request to compare with * @returns {boolean} True if requests are equal */ equals(other) { @@ -77,14 +96,22 @@ export class Request { } /** - * Node in the request set graph + * Represents a node in the request set graph + * + * Each node stores a delta of requests added at this level, with an optional parent + * reference. The full request set is computed by traversing up the parent chain. + * This enables efficient storage through delta encoding. + * + * @class */ class RequestSetNode { /** - * @param {number} id - Unique node identifier - * @param {number|null} parent - Parent node ID or null - * @param {Request[]} addedRequests - Requests added in this node (delta) - * @param {*} metadata - Associated metadata + * Creates a new RequestSetNode instance + * + * @param {number} id Unique node identifier + * @param {number|null} [parent=null] Parent node ID or null for root nodes + * @param {Request[]} [addedRequests=[]] Requests added in this node (delta from parent) + * @param {*} [metadata={}] Associated metadata */ constructor(id, parent = null, addedRequests = [], metadata = {}) { this.id = id; @@ -98,9 +125,12 @@ class RequestSetNode { } /** - * Get the full materialized set of requests for this node + * Gets the full materialized set of requests for this node + * + * Computes the complete set of requests by traversing up the parent chain + * and collecting all added requests. Results are cached for performance. * - * @param {ResourceRequestGraph} graph - The graph containing this node + * @param {ResourceRequestGraph} graph The graph containing this node * @returns {Set} Set of request keys */ getMaterializedSet(graph) { @@ -127,7 +157,10 @@ class RequestSetNode { } /** - * Invalidate cache (called when graph structure changes) + * Invalidates the materialized set cache + * + * Should be called when the graph structure changes to ensure the cached + * materialized set is recomputed on next access. */ invalidateCache() { this._cacheValid = false; @@ -135,9 +168,11 @@ class RequestSetNode { } /** - * Get full set as Request objects + * Gets the full set of requests as Request objects + * + * Similar to getMaterializedSet but returns Request instances instead of keys. * - * @param {ResourceRequestGraph} graph - The graph containing this node + * @param {ResourceRequestGraph} graph The graph containing this node * @returns {Request[]} Array of Request objects */ getMaterializedRequests(graph) { @@ -146,7 +181,10 @@ class RequestSetNode { } /** - * Get only the requests added in this node (delta, not including parent requests) + * Gets only the requests added in this node (delta) + * + * Returns the requests added at this level, not including parent requests. + * This is the delta that was stored in the node. * * @returns {Request[]} Array of Request objects added in this node */ @@ -154,6 +192,11 @@ class RequestSetNode { return Array.from(this.addedRequests).map((key) => Request.fromKey(key)); } + /** + * Gets the parent node ID + * + * @returns {number|null} Parent node ID or null if this is a root node + */ getParentId() { return this.parent; } @@ -161,17 +204,32 @@ class RequestSetNode { /** * Graph managing request set nodes with delta encoding + * + * This graph structure optimizes storage of multiple related request sets by using + * delta encoding - each node stores only the requests added relative to its parent. + * This is particularly efficient when request sets have significant overlap. + * + * The graph automatically finds the best parent for new request sets to minimize + * the delta size and maintain efficient storage. + * + * @class */ export default class ResourceRequestGraph { + /** + * Creates a new ResourceRequestGraph instance + * + * @public + */ constructor() { this.nodes = new Map(); // nodeId -> RequestSetNode this.nextId = 1; } /** - * Get a node by ID + * Gets a node by ID * - * @param {number} nodeId - Node identifier + * @public + * @param {number} nodeId Node identifier * @returns {RequestSetNode|undefined} The node or undefined if not found */ getNode(nodeId) { @@ -179,8 +237,9 @@ export default class ResourceRequestGraph { } /** - * Get all node IDs + * Gets all node IDs in the graph * + * @public * @returns {number[]} Array of all node IDs */ getAllNodeIds() { @@ -188,10 +247,13 @@ export default class ResourceRequestGraph { } /** - * Calculate which requests need to be added (delta) + * Calculates which requests need to be added (delta) * - * @param {Request[]} newRequestSet - New request set - * @param {Set} parentSet - Parent's materialized set (keys) + * Determines the difference between a new request set and a parent's materialized set, + * returning only the requests that need to be stored in the delta. + * + * @param {Request[]} newRequestSet New request set + * @param {Set} parentSet Parent's materialized set (as keys) * @returns {Request[]} Array of requests to add */ _calculateAddedRequests(newRequestSet, parentSet) { @@ -202,10 +264,14 @@ export default class ResourceRequestGraph { } /** - * Add a new request set to the graph + * Adds a new request set to the graph + * + * Automatically finds the best parent node (largest subset) and stores only + * the delta of requests. If no suitable parent is found, creates a root node. * - * @param {Request[]} requests - Array of Request objects - * @param {*} metadata - Optional metadata to store with this node + * @public + * @param {Request[]} requests Array of Request objects + * @param {*} [metadata=null] Optional metadata to store with this node * @returns {number} The new node ID */ addRequestSet(requests, metadata = null) { @@ -233,10 +299,14 @@ export default class ResourceRequestGraph { } /** - * Find the best parent for a new request set. That is, the largest subset of the new request set. + * Finds the best parent for a new request set * - * @param {Request[]} requestSet - Array of Request objects - * @returns {{parentId: number, deltaSize: number}|null} Parent info or null if no suitable parent + * Searches for the existing node with the largest subset of the new request set. + * This minimizes the delta size and optimizes storage efficiency. + * + * @public + * @param {Request[]} requestSet Array of Request objects + * @returns {number|null} Parent node ID or null if no suitable parent exists */ findBestParent(requestSet) { if (this.nodes.size === 0) { @@ -265,9 +335,13 @@ export default class ResourceRequestGraph { } /** - * Find a node with an identical request set + * Finds a node with an identical request set + * + * Searches for an existing node whose materialized request set exactly matches + * the given request set. Used to avoid creating duplicate nodes. * - * @param {Request[]} requests - Array of Request objects + * @public + * @param {Request[]} requests Array of Request objects * @returns {number|null} Node ID of exact match, or null if no match found */ findExactMatch(requests) { @@ -295,9 +369,10 @@ export default class ResourceRequestGraph { } /** - * Get metadata associated with a node + * Gets metadata associated with a node * - * @param {number} nodeId - Node identifier + * @public + * @param {number} nodeId Node identifier * @returns {*} Metadata or null if node not found */ getMetadata(nodeId) { @@ -306,10 +381,11 @@ export default class ResourceRequestGraph { } /** - * Update metadata for a node + * Updates metadata for a node * - * @param {number} nodeId - Node identifier - * @param {*} metadata - New metadata value + * @public + * @param {number} nodeId Node identifier + * @param {*} metadata New metadata value */ setMetadata(nodeId, metadata) { const node = this.getNode(nodeId); @@ -319,8 +395,11 @@ export default class ResourceRequestGraph { } /** - * Get a set containing all unique requests across all nodes in the graph + * Gets all unique requests across all nodes in the graph * + * Collects the union of all materialized request sets from every node. + * + * @public * @returns {Request[]} Array of all unique Request objects in the graph */ getAllRequests() { @@ -337,7 +416,19 @@ export default class ResourceRequestGraph { } /** - * Get statistics about the graph + * Gets statistics about the graph structure + * + * Provides metrics about the graph's efficiency, including node count, + * average requests per node, storage overhead, and tree depth statistics. + * + * @public + * @returns {object} Statistics object + * @returns {number} return.nodeCount Total number of nodes + * @returns {number} return.averageRequestsPerNode Average materialized requests per node + * @returns {number} return.averageStoredDeltaSize Average stored delta size per node + * @returns {number} return.averageDepth Average depth in the tree + * @returns {number} return.maxDepth Maximum depth in the tree + * @returns {number} return.compressionRatio Ratio of stored deltas to total requests (lower is better) */ getStats() { let totalRequests = 0; @@ -368,17 +459,29 @@ export default class ResourceRequestGraph { }; } + /** + * Gets the number of nodes in the graph + * + * @public + * @returns {number} Node count + */ getSize() { return this.nodes.size; } /** - * Iterate through nodes in breadth-first order (by depth level). + * Iterates through nodes in breadth-first order (by depth level) + * * Parents are always yielded before their children, allowing efficient traversal * where you can check parent nodes first and only examine deltas of subtrees as needed. * - * @yields {{nodeId: number, node: RequestSetNode, depth: number, parentId: number|null}} - * Node information including ID, node instance, depth level, and parent ID + * @public + * @generator + * @yields {object} Node information + * @yields {number} return.nodeId Node identifier + * @yields {RequestSetNode} return.node Node instance + * @yields {number} return.depth Depth level in the tree + * @yields {number|null} return.parentId Parent node ID or null for root nodes * * @example * // Traverse all nodes, checking parents before children @@ -443,16 +546,22 @@ export default class ResourceRequestGraph { } /** - * Iterate through nodes starting from a specific node, traversing its subtree. - * Useful for examining only a portion of the graph. + * Iterates through nodes starting from a specific node, traversing its subtree * - * @param {number} startNodeId - Node ID to start traversal from - * @yields {{nodeId: number, node: RequestSetNode, depth: number, parentId: number|null}} - * Node information including ID, node instance, relative depth from start, and parent ID + * Useful for examining only a portion of the graph rooted at a particular node. + * + * @public + * @generator + * @param {number} startNodeId Node ID to start traversal from + * @yields {object} Node information + * @yields {number} return.nodeId Node identifier + * @yields {RequestSetNode} return.node Node instance + * @yields {number} return.depth Relative depth from the start node + * @yields {number|null} return.parentId Parent node ID or null * * @example * // Traverse only the subtree under a specific node - * const matchNodeId = graph.findBestMatch(query); + * const matchNodeId = graph.findBestParent(query); * for (const {nodeId, node, depth} of graph.traverseSubtree(matchNodeId)) { * console.log(`Processing node ${nodeId} at relative depth ${depth}`); * } @@ -499,9 +608,10 @@ export default class ResourceRequestGraph { } /** - * Get all children node IDs for a given parent node + * Gets all children node IDs for a given parent node * - * @param {number} parentId - Parent node identifier + * @public + * @param {number} parentId Parent node identifier * @returns {number[]} Array of child node IDs */ getChildren(parentId) { @@ -515,10 +625,15 @@ export default class ResourceRequestGraph { } /** - * Export graph structure for serialization + * Exports graph structure for serialization + * + * Converts the graph to a plain object suitable for JSON serialization. + * Metadata is not included in the export and must be handled separately. * - * @returns {{nodes: Array<{id: number, parent: number|null, addedRequests: string[]}>, nextId: number}} - * Graph structure with metadata + * @public + * @returns {object} Graph structure + * @returns {Array} return.nodes Array of node objects with id, parent, and addedRequests + * @returns {number} return.nextId Next available node ID */ toCacheObject() { const nodes = []; @@ -535,10 +650,15 @@ export default class ResourceRequestGraph { } /** - * Create a graph from JSON structure (as produced by toCacheObject) + * Creates a graph from a serialized cache object + * + * Reconstructs the graph structure from a plain object produced by toCacheObject(). + * Metadata must be restored separately if needed. * - * @param {{nodes: Array<{id: number, parent: number|null, addedRequests: string[]}>, nextId: number}} metadata - * JSON representation of the graph + * @public + * @param {object} metadata Serialized graph structure + * @param {Array} metadata.nodes Array of node objects with id, parent, and addedRequests + * @param {number} metadata.nextId Next available node ID * @returns {ResourceRequestGraph} Reconstructed graph instance */ static fromCacheObject(metadata) { diff --git a/packages/project/lib/build/cache/StageCache.js b/packages/project/lib/build/cache/StageCache.js index cd9031c197a..b40b19dbff0 100644 --- a/packages/project/lib/build/cache/StageCache.js +++ b/packages/project/lib/build/cache/StageCache.js @@ -1,7 +1,7 @@ /** * @typedef {object} StageCacheEntry - * @property {object} stage - The cached stage instance (typically a reader or writer) - * @property {string[]} writtenResourcePaths - Set of resource paths written during stage execution + * @property {object} stage The cached stage instance (typically a reader or writer) + * @property {string[]} writtenResourcePaths Array of resource paths written during stage execution */ /** @@ -19,6 +19,8 @@ * - Tracks written resources for cache invalidation * - Supports batch persistence via flush queue * - Multiple signatures per stage ID (for different input combinations) + * + * @class */ export default class StageCache { #stageIdToSignatures = new Map(); @@ -33,11 +35,11 @@ export default class StageCache { * Multiple signatures can exist for the same stage ID, representing different * input resource combinations that produce different outputs. * - * @param {string} stageId - Identifier for the stage (e.g., "task/generateBundle") - * @param {string} signature - Content hash signature of the stage's input resources - * @param {object} stageInstance - The stage instance to cache (typically a reader or writer) - * @param {string[]} writtenResourcePaths - Set of resource paths written during this stage - * @returns {void} + * @public + * @param {string} stageId Identifier for the stage (e.g., "task/generateBundle") + * @param {string} signature Content hash signature of the stage's input resources + * @param {object} stageInstance The stage instance to cache (typically a reader or writer) + * @param {string[]} writtenResourcePaths Array of resource paths written during this stage */ addSignature(stageId, signature, stageInstance, writtenResourcePaths) { if (!this.#stageIdToSignatures.has(stageId)) { @@ -58,8 +60,9 @@ export default class StageCache { * Looks up a previously cached stage by its ID and signature. Returns null * if either the stage ID or signature is not found in the cache. * - * @param {string} stageId - Identifier for the stage to look up - * @param {string} signature - Signature hash to match + * @public + * @param {string} stageId Identifier for the stage to look up + * @param {string} signature Signature hash to match * @returns {StageCacheEntry|null} Cached stage entry with stage instance and written paths, * or null if not found */ @@ -80,7 +83,8 @@ export default class StageCache { * Each queue entry is a tuple of [stageId, signature] that can be used to * retrieve the full stage data via getCacheForSignature(). * - * @returns {Array<[string, string]>} Array of [stageId, signature] tuples that need persistence + * @public + * @returns {Array<[string, string]>} Array of [stageId, signature] tuples to persist */ flushCacheQueue() { const queue = this.#cacheQueue; @@ -91,6 +95,7 @@ export default class StageCache { /** * Checks if there are pending entries in the cache queue * + * @public * @returns {boolean} True if there are entries to flush, false otherwise */ hasPendingCacheQueue() { diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 2b16d6105ca..e6b93545d1c 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -7,11 +7,13 @@ */ /** - * Compares a resource instance with cached resource metadata. + * Compares a resource instance with cached resource metadata * - * Optimized for quickly rejecting changed files + * Optimized for quickly rejecting changed files. Performs a series of checks + * starting with the cheapest (timestamp) to more expensive (integrity hash). * - * @param {object} resource Resource instance to compare + * @public + * @param {@ui5/fs/Resource} resource Resource instance to compare * @param {ResourceMetadata} resourceMetadata Resource metadata to compare against * @param {number} [indexTimestamp] Timestamp of the metadata creation * @returns {Promise} True if resource is found to match the metadata @@ -54,12 +56,14 @@ export async function matchResourceMetadata(resource, resourceMetadata, indexTim /** * Determines if a resource has changed compared to cached metadata * - * Optimized for quickly accepting unchanged files. - * I.e. Resources are assumed to be usually unchanged (same lastModified timestamp) + * Optimized for quickly accepting unchanged files. Resources are assumed to be + * usually unchanged (same lastModified timestamp). Performs checks from cheapest + * to most expensive, falling back to integrity comparison when necessary. * - * @param {object} resource - Resource instance with methods: getInode(), getSize(), getLastModified(), getIntegrity() - * @param {ResourceMetadata} cachedMetadata - Cached metadata from the tree - * @param {number} [indexTimestamp] - Timestamp when the tree state was created + * @public + * @param {@ui5/fs/Resource} resource Resource instance to compare + * @param {ResourceMetadata} cachedMetadata Cached metadata from the tree + * @param {number} [indexTimestamp] Timestamp when the tree state was created * @returns {Promise} True if resource content is unchanged * @throws {Error} If resource or metadata is undefined */ @@ -100,12 +104,16 @@ export async function matchResourceMetadataStrict(resource, cachedMetadata, inde /** - * Creates an index of resource metadata from an array of resources. + * Creates an index of resource metadata from an array of resources * - * @param {Array<@ui5/fs/Resource>} resources - Array of resources to index - * @param {boolean} [includeInode=false] - Whether to include inode information in the metadata + * Processes all resources in parallel, extracting their metadata including + * path, integrity, lastModified timestamp, and size. Optionally includes inode information. + * + * @public + * @param {Array<@ui5/fs/Resource>} resources Array of resources to index + * @param {boolean} [includeInode=false] Whether to include inode information in the metadata * @returns {Promise>} - * Array of resource metadata objects + * Array of resource metadata objects */ export async function createResourceIndex(resources, includeInode = false) { return await Promise.all(resources.map(async (resource) => { @@ -129,8 +137,7 @@ export async function createResourceIndex(resources, includeInode = false) { * when the first truthy value is found. If all promises resolve to falsy * values, null is returned. * - * @private - * @param {Promise[]} promises - Array of promises to evaluate + * @param {Promise[]} promises Array of promises to evaluate * @returns {Promise<*>} The first truthy resolved value or null if all are falsy */ export async function firstTruthy(promises) { diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 438916cf359..9410da4a524 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -10,7 +10,6 @@ import {getBaseSignature} from "./getBuildSignature.js"; * @memberof @ui5/project/build/helpers */ class BuildContext { - #watchHandler; #cacheManager; constructor(graph, taskRepository, { // buildConfig diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index d611d4fa6b0..4d4e91c762f 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -8,18 +8,18 @@ import ProjectBuildCache from "../cache/ProjectBuildCache.js"; /** * Build context of a single project. Always part of an overall * [Build Context]{@link @ui5/project/build/helpers/BuildContext} - * - * @private + * @memberof @ui5/project/build/helpers */ class ProjectBuildContext { /** + * Creates a new ProjectBuildContext instance * - * @param {object} buildContext The build context. - * @param {object} project The project instance. - * @param {string} buildSignature The signature of the build. - * @param {ProjectBuildCache} buildCache - * @throws {Error} Throws an error if 'buildContext' or 'project' is missing. + * @param {@ui5/project/build/helpers/BuildContext} buildContext Overall build context + * @param {@ui5/project/specifications/Project} project Project instance to build + * @param {string} buildSignature Signature of the build configuration + * @param {@ui5/project/build/cache/ProjectBuildCache} buildCache Build cache instance + * @throws {Error} If 'buildContext' or 'project' is missing */ constructor(buildContext, project, buildSignature, buildCache) { if (!buildContext) { @@ -47,6 +47,18 @@ class ProjectBuildContext { }); } + /** + * Factory method to create and initialize a ProjectBuildContext instance + * + * This is the recommended way to create a ProjectBuildContext as it ensures + * proper initialization of the build signature and cache. + * + * @param {@ui5/project/build/helpers/BuildContext} buildContext Overall build context + * @param {@ui5/project/specifications/Project} project Project instance to build + * @param {object} cacheManager Cache manager instance + * @param {string} baseSignature Base signature for the build + * @returns {Promise<@ui5/project/build/helpers/ProjectBuildContext>} Initialized context instance + */ static async create(buildContext, project, cacheManager, baseSignature) { const buildSignature = getProjectSignature( baseSignature, project, buildContext.getGraph(), buildContext.getTaskRepository()); @@ -60,19 +72,45 @@ class ProjectBuildContext { ); } - + /** + * Checks whether this context is for the root project + * + * @returns {boolean} True if this is the root project context + */ isRootProject() { return this._project === this._buildContext.getRootProject(); } + /** + * Retrieves a build configuration option + * + * @param {string} key Option key to retrieve + * @returns {*} Option value + */ getOption(key) { return this._buildContext.getOption(key); } + /** + * Registers a cleanup task to be executed after the build + * + * Cleanup tasks are called after all regular tasks have completed, + * allowing resources to be freed or temporary data to be cleaned up. + * + * @param {Function} callback Cleanup callback function that accepts a force parameter + */ registerCleanupTask(callback) { this._queues.cleanup.push(callback); } + /** + * Executes all registered cleanup tasks + * + * Calls all cleanup callbacks in parallel and clears the cleanup queue. + * + * @param {boolean} force Whether to force cleanup even if conditions aren't met + * @returns {Promise} + */ async executeCleanupTasks(force) { await Promise.all(this._queues.cleanup.map((callback) => { return callback(force); @@ -81,11 +119,12 @@ class ProjectBuildContext { } /** - * Retrieve a single project from the dependency graph + * Retrieves a single project from the dependency graph * - * @param {string} [projectName] Name of the project to retrieve. Defaults to the project currently being built + * @param {string} [projectName] Name of the project to retrieve. + * Defaults to the project currently being built * @returns {@ui5/project/specifications/Project|undefined} - * project instance or undefined if the project is unknown to the graph + * Project instance or undefined if the project is unknown to the graph */ getProject(projectName) { if (projectName) { @@ -95,9 +134,10 @@ class ProjectBuildContext { } /** - * Retrieve a list of direct dependencies of a given project from the dependency graph + * Retrieves a list of direct dependencies of a given project from the dependency graph * - * @param {string} [projectName] Name of the project to retrieve. Defaults to the project currently being built + * @param {string} [projectName] Name of the project to retrieve. + * Defaults to the project currently being built * @returns {string[]} Names of all direct dependencies * @throws {Error} If the requested project is unknown to the graph */ @@ -105,6 +145,14 @@ class ProjectBuildContext { return this._buildContext.getGraph().getDependencies(projectName || this._project.getName()); } + /** + * Gets the list of required dependencies for the current project + * + * Determines which dependencies are actually needed based on the tasks that will be executed. + * Results are cached after the first call. + * + * @returns {Promise} Array of required dependency names + */ async getRequiredDependencies() { if (this._requiredDependencies) { return this._requiredDependencies; @@ -114,6 +162,18 @@ class ProjectBuildContext { return this._requiredDependencies; } + /** + * Gets the appropriate resource tag collection for a resource and tag + * + * Determines which tag collection (project-specific or build-level) should be used + * for the given resource and tag combination. Associates the resource with the current + * project if not already associated. + * + * @param {@ui5/fs/Resource} resource Resource to get tag collection for + * @param {string} tag Tag to check acceptance for + * @returns {@ui5/fs/internal/ResourceTagCollection} Appropriate tag collection + * @throws {Error} If no collection accepts the given tag + */ getResourceTagCollection(resource, tag) { if (!resource.hasProject()) { this._log.silly(`Associating resource ${resource.getPath()} with project ${this._project.getName()}`); @@ -132,6 +192,14 @@ class ProjectBuildContext { throw new Error(`Could not find collection for resource ${resource.getPath()} and tag ${tag}`); } + /** + * Gets the task utility instance for this build context + * + * Creates a TaskUtil instance on first access and caches it for subsequent calls. + * The TaskUtil provides helper functions for tasks during execution. + * + * @returns {@ui5/project/build/helpers/TaskUtil} Task utility instance + */ getTaskUtil() { if (!this._taskUtil) { this._taskUtil = new TaskUtil({ @@ -142,6 +210,14 @@ class ProjectBuildContext { return this._taskUtil; } + /** + * Gets the task runner instance for this build context + * + * Creates a TaskRunner instance on first access and caches it for subsequent calls. + * The TaskRunner is responsible for executing all build tasks for the project. + * + * @returns {@ui5/project/build/TaskRunner} Task runner instance + */ getTaskRunner() { if (!this._taskRunner) { this._taskRunner = new TaskRunner({ @@ -177,17 +253,17 @@ class ProjectBuildContext { } /** - * Prepares the project build by updating, and then validating the build cache as needed + * Prepares the project build by updating and validating the build cache + * + * Creates a dependency reader and validates the cache state against current resources. + * Must be called before buildProject(). * - * @param {boolean} initialBuild - * @returns {Promise} Undefined if no cache has been found. Otherwise a list of changed - * resources + * @param {boolean} initialBuild Whether this is the initial build (forces dependency index update) + * @returns {Promise} + * Undefined if no cache was found, false if cache is empty, + * or an array of changed resource paths since the last build */ async prepareProjectBuildAndValidateCache(initialBuild) { - // if (this.getBuildCache().hasCache() && this.getBuildCache().requiresDependencyIndexInitialization()) { - // const depReader = this.getTaskRunner().getDependenciesReader(this.getTaskRunner.getRequiredDependencies()); - // await this.getBuildCache().updateDependencyCache(depReader); - // } const depReader = await this.getTaskRunner().getDependenciesReader( await this.getTaskRunner().getRequiredDependencies(), true, // Force creation of new reader since project readers might have changed during their (re-)build @@ -198,9 +274,11 @@ class ProjectBuildContext { /** * Builds the project by running all required tasks - * Requires prepareProjectBuildAndValidateCache to be called beforehand * - * @returns {Promise} Resolves with list of changed resources since the last build + * Executes all configured build tasks for the project using the task runner. + * Must be called after prepareProjectBuildAndValidateCache(). + * + * @returns {Promise} List of changed resource paths since the last build */ async buildProject() { return await this.getTaskRunner().runTasks(); @@ -208,7 +286,10 @@ class ProjectBuildContext { /** * Informs the build cache about changed project source resources * - * @param {string[]} changedPaths - Changed project source file paths + * Notifies the cache that source files have changed so it can invalidate + * affected cache entries and mark the cache as stale. + * + * @param {string[]} changedPaths Changed project source file paths */ projectSourcesChanged(changedPaths) { return this._buildCache.projectSourcesChanged(changedPaths); @@ -217,12 +298,23 @@ class ProjectBuildContext { /** * Informs the build cache about changed dependency resources * - * @param {string[]} changedPaths - Changed dependency resource paths + * Notifies the cache that dependency resources have changed so it can invalidate + * affected cache entries and mark the cache as stale. + * + * @param {string[]} changedPaths Changed dependency resource paths */ dependencyResourcesChanged(changedPaths) { return this._buildCache.dependencyResourcesChanged(changedPaths); } + /** + * Gets the build manifest if available and compatible + * + * Retrieves the project's build manifest and validates its version. + * Only manifest versions 0.1 and 0.2 are currently supported. + * + * @returns {object|undefined} Build manifest object or undefined if unavailable or incompatible + */ #getBuildManifest() { const manifest = this._project.getBuildManifest(); if (!manifest) { @@ -237,6 +329,15 @@ class ProjectBuildContext { return; } + /** + * Gets metadata about the previous build from the build manifest + * + * Extracts timestamp and age information from the build manifest if available. + * + * @returns {object|null} Build metadata with timestamp and age, or null if no manifest exists + * @returns {string} return.timestamp ISO timestamp of the previous build + * @returns {string} return.age Human-readable age of the previous build + */ getBuildMetadata() { const buildManifest = this.#getBuildManifest(); if (!buildManifest) { @@ -251,10 +352,23 @@ class ProjectBuildContext { }; } + /** + * Gets the project build cache instance + * + * @returns {@ui5/project/build/cache/ProjectBuildCache} Build cache instance + */ getBuildCache() { return this._buildCache; } + /** + * Gets the build signature for this project + * + * The build signature uniquely identifies the build configuration and dependencies, + * used for cache validation and invalidation. + * + * @returns {string} Build signature string + */ getBuildSignature() { return this._buildSignature; } diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index 449a7d0bcec..9f546a47685 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -1328,6 +1328,7 @@ test("traverseDependenciesDepthFirst: Can't find start node", async (t) => { graph.addProject(await createProject("library.a")); const error = t.throws(() => { + // eslint-disable-next-line no-unused-vars for (const result of graph.traverseDependenciesDepthFirst("library.nonexistent")) { // Should not reach here } @@ -1385,6 +1386,7 @@ test("traverseDependenciesDepthFirst: Detect cycle", async (t) => { graph.declareDependency("library.b", "library.a"); const error = t.throws(() => { + // eslint-disable-next-line no-unused-vars for (const result of graph.traverseDependenciesDepthFirst("library.a")) { // Should not complete iteration } @@ -1624,6 +1626,7 @@ test("traverseDependents: Can't find start node", async (t) => { const error = t.throws(() => { // Consume the generator to trigger the error + // eslint-disable-next-line no-unused-vars for (const result of graph.traverseDependents("library.nonexistent")) { // Should not reach here } @@ -1683,6 +1686,7 @@ test("traverseDependents: Detect cycle", async (t) => { graph.declareDependency("library.b", "library.a"); const error = t.throws(() => { + // eslint-disable-next-line no-unused-vars for (const result of graph.traverseDependents("library.a")) { // Should not complete iteration } From a9ce6053e59a53b9e18ac332d381488edf0030ab Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 20 Jan 2026 21:32:17 +0100 Subject: [PATCH 104/223] refactor(project): Add cache write perf logging --- packages/project/lib/build/ProjectBuilder.js | 6 ++---- packages/project/lib/build/cache/ProjectBuildCache.js | 11 +++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 79f43399048..b5afa95c350 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -324,13 +324,11 @@ class ProjectBuilder { if (includedDependencies.length === this._graph.getSize() - 1) { this.#log.info(` Including all dependencies`); } else { - this.#log.info(` Requested dependencies:`); - this.#log.info(` + ${includedDependencies.join("\n + ")}`); + this.#log.info(` Requested dependencies:\n + ${includedDependencies.join("\n + ")}`); } } if (excludedDependencies.length) { - this.#log.info(` Excluded dependencies:`); - this.#log.info(` - ${excludedDependencies.join("\n + ")}`); + this.#log.info(` Excluded dependencies:\n - ${excludedDependencies.join("\n + ")}`); } const rootProjectName = this._graph.getRoot().getName(); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index f71122b94da..459e585159c 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -840,12 +840,10 @@ export default class ProjectBuildCache { * 3. Stores all stage caches from the queue * * @public - * @param {object} buildManifest Build manifest containing metadata about the build - * @param {string} buildManifest.manifestVersion Version of the manifest format - * @param {string} buildManifest.signature Build signature * @returns {Promise} */ - async writeCache(buildManifest) { + async writeCache() { + const cacheWriteStart = performance.now(); await Promise.all([ this.#writeResultCache(), @@ -854,6 +852,11 @@ export default class ProjectBuildCache { this.#writeSourceIndex(), ]); + if (log.isLevelEnabled("perf")) { + log.perf( + `Wrote build cache for project ${this.#project.getName()} in ` + + `${(performance.now() - cacheWriteStart).toFixed(2)} ms`); + } } /** From 82892e7a050cd939c6e897c55ce41bd174a6016a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 21 Jan 2026 10:35:12 +0100 Subject: [PATCH 105/223] refactor(project): Improve stage change handling --- .../lib/build/cache/ProjectBuildCache.js | 56 ++++++++++++------- .../project/lib/specifications/Project.js | 4 +- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 459e585159c..bb3f6f52c7a 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -294,9 +294,19 @@ export default class ProjectBuildCache { this.#project.useResultStage(); const writtenResourcePaths = new Set(); for (const [stageName, stageCache] of importedStages) { - this.#project.setStage(stageName, stageCache.stage); - for (const resourcePath of stageCache.writtenResourcePaths) { - writtenResourcePaths.add(resourcePath); + // Check whether the stage differs form the one currently in use + if (this.#currentStageSignatures.get(stageName)?.join("-") !== stageCache.signature) { + // Set stage + this.#project.setStage(stageName, stageCache.stage); + + // Store signature for later use in result stage signature calculation + this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); + + // Cached stage likely differs from the previous one (if any) + // Add all resources written by the cached stage to the set of written/potentially changed resources + for (const resourcePath of stageCache.writtenResourcePaths) { + writtenResourcePaths.add(resourcePath); + } } } return Array.from(writtenResourcePaths); @@ -391,15 +401,17 @@ export default class ProjectBuildCache { }); const stageCache = await this.#findStageCache(stageName, stageSignatures); + const oldStageSig = this.#currentStageSignatures.get(stageName)?.join("-"); if (stageCache) { - const stageChanged = this.#project.setStage(stageName, stageCache.stage); + // Check whether the stage actually changed + if (stageCache.signature !== oldStageSig) { + this.#project.setStage(stageName, stageCache.stage); - // Store dependency signature for later use in result stage signature calculation - this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); + // Store new stage signature for later use in result stage signature calculation + this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); - // Cached stage might differ from the previous one - // Add all resources written by the cached stage to the set of written/potentially changed resources - if (stageChanged) { + // Cached stage likely differs from the previous one (if any) + // Add all resources written by the cached stage to the set of written/potentially changed resources for (const resourcePath of stageCache.writtenResourcePaths) { if (!this.#writtenResultResourcePaths.includes(resourcePath)) { this.#writtenResultResourcePaths.push(resourcePath); @@ -439,7 +451,21 @@ export default class ProjectBuildCache { if (deltaStageCache) { // Store dependency signature for later use in result stage signature calculation const [foundProjectSig, foundDepSig] = deltaStageCache.signature.split("-"); - this.#currentStageSignatures.set(stageName, [foundProjectSig, foundDepSig]); + + // Check whether the stage actually changed + if (oldStageSig !== deltaStageCache.signature) { + this.#currentStageSignatures.set(stageName, [foundProjectSig, foundDepSig]); + + // Cached stage likely differs from the previous one (if any) + // Add all resources written by the cached stage to the set of written/potentially changed resources + for (const resourcePath of deltaStageCache.writtenResourcePaths) { + if (!this.#writtenResultResourcePaths.includes(resourcePath)) { + this.#writtenResultResourcePaths.push(resourcePath); + } + } + } + + // Create new signature and determine changed resource paths const projectDeltaInfo = projectDeltas.get(foundProjectSig); const dependencyDeltaInfo = depDeltas.get(foundDepSig); @@ -447,14 +473,6 @@ export default class ProjectBuildCache { projectDeltaInfo?.newSignature ?? foundProjectSig, dependencyDeltaInfo?.newSignature ?? foundDepSig); - // Using cached stage which might differ from the previous one - // Add all resources written by the cached stage to the set of written/potentially changed resources - for (const resourcePath of deltaStageCache.writtenResourcePaths) { - if (!this.#writtenResultResourcePaths.includes(resourcePath)) { - this.#writtenResultResourcePaths.push(resourcePath); - } - } - log.verbose( `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + `with original signature ${deltaStageCache.signature} (now ${newSignature}) ` + @@ -616,8 +634,8 @@ export default class ProjectBuildCache { log.verbose(`Caching stage for task ${taskName} in project ${this.#project.getName()} ` + `with signature ${stageSignature}`); + // Store resulting stage in stage cache - // TODO: Check whether signature already exists and avoid invalidating following tasks this.#stageCache.addSignature( this.#getStageNameForTask(taskName), stageSignature, this.#project.getStage(), writtenResourcePaths); diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index dae4c8974d0..e366aee917a 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -456,7 +456,7 @@ class Project extends Specification { if (stageOrCachedWriter instanceof Stage) { newStage = stageOrCachedWriter; if (oldStage === newStage) { - // Same stage as before + // Same stage as before, nothing to do return false; // Stored stage has not changed } } else { @@ -464,7 +464,7 @@ class Project extends Specification { } this.#stages[stageIdx] = newStage; - // Update current stage reference if necessary + // If we are updating the current stage, make sure to update and reset all relevant references if (oldStage === this.#currentStage) { this.#currentStage = newStage; // Unset "current" reader/writer. They might be outdated From 9f2bed9e7a710fad44c1f0f48c082cea0c819673 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 21 Jan 2026 12:49:39 +0100 Subject: [PATCH 106/223] refactor(project): Implement queue system in BuildServer --- packages/project/lib/build/BuildServer.js | 219 +++++++++++++--------- 1 file changed, 132 insertions(+), 87 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 0d3bc17e6e0..2018c4cc98c 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -2,6 +2,8 @@ import EventEmitter from "node:events"; import {createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; import BuildReader from "./BuildReader.js"; import WatchHandler from "./helpers/WatchHandler.js"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:BuildServer"); /** * Development server that provides access to built project resources with automatic rebuilding @@ -27,7 +29,9 @@ import WatchHandler from "./helpers/WatchHandler.js"; class BuildServer extends EventEmitter { #graph; #projectBuilder; - #pCurrentBuild; + #buildQueue = new Map(); + #pendingBuildRequest = new Set(); + #activeBuild = null; #allReader; #rootReader; #dependenciesReader; @@ -65,12 +69,13 @@ class BuildServer extends EventEmitter { this.#getReaderForProjects.bind(this)); if (initialBuildIncludedDependencies.length > 0) { - this.#pCurrentBuild = projectBuilder.build({ - includedDependencies: initialBuildIncludedDependencies, - excludedDependencies: initialBuildExcludedDependencies - }).then((builtProjects) => { - this.#projectBuildFinished(builtProjects); - }).catch((err) => { + // Enqueue initial build dependencies + for (const projectName of initialBuildIncludedDependencies) { + if (!initialBuildExcludedDependencies.includes(projectName)) { + this.#pendingBuildRequest.add(projectName); + } + } + this.#processBuildQueue().catch((err) => { this.emit("error", err); }); } @@ -86,10 +91,19 @@ class BuildServer extends EventEmitter { }); watchHandler.on("sourcesChanged", (changes) => { // Inform project builder + + log.verbose("Source changes detected: ", changes); + const affectedProjects = this.#projectBuilder.resourcesChanged(changes); for (const projectName of affectedProjects) { + log.verbose(`Invalidating built project '${projectName}' due to source changes`); this.#projectReaders.delete(projectName); + // If project is currently in build queue, re-enqueue it for rebuild + if (this.#buildQueue.has(projectName)) { + log.verbose(`Re-enqueuing project '${projectName}' for rebuild`); + this.#pendingBuildRequest.add(projectName); + } } const changedResourcePaths = [...changes.values()].flat(); @@ -142,8 +156,8 @@ class BuildServer extends EventEmitter { * Gets a reader for a single project, building it if necessary * * Checks if the project has already been built and returns its reader from cache. - * If not built, waits for any in-progress build, then triggers a build for the - * requested project. + * If not built, enqueues the project for building and returns a promise that + * resolves when the reader is available. * * @param {string} projectName Name of the project to get reader for * @returns {Promise<@ui5/fs/AbstractReader>} Reader for the built project @@ -152,73 +166,30 @@ class BuildServer extends EventEmitter { if (this.#projectReaders.has(projectName)) { return this.#projectReaders.get(projectName); } - if (this.#pCurrentBuild) { - // If set, await currently running build - await this.#pCurrentBuild; - } - if (this.#projectReaders.has(projectName)) { - return this.#projectReaders.get(projectName); - } - this.#pCurrentBuild = this.#projectBuilder.build({ - includedDependencies: [projectName] - }).catch((err) => { - this.emit("error", err); - }); - const builtProjects = await this.#pCurrentBuild; - this.#projectBuildFinished(builtProjects); - - // Clear current build promise - this.#pCurrentBuild = null; - - return this.#projectReaders.get(projectName); + return this.#enqueueBuild(projectName); } /** * Gets a combined reader for multiple projects, building them if necessary * - * Determines which projects need to be built, waits for any in-progress build, - * then triggers a build for any missing projects. Returns a prioritized collection - * reader combining all requested projects. + * Enqueues all projects that need to be built and waits for all of them to complete. + * Returns a prioritized collection reader combining all requested projects. * * @param {string[]} projectNames Array of project names to get readers for * @returns {Promise<@ui5/fs/ReaderCollection>} Combined reader for all requested projects */ async #getReaderForProjects(projectNames) { - let projectsRequiringBuild = []; - for (const projectName of projectNames) { - if (!this.#projectReaders.has(projectName)) { - projectsRequiringBuild.push(projectName); - } - } - if (projectsRequiringBuild.length === 0) { - // Projects already built - return this.#getReaderForCachedProjects(projectNames); - } - if (this.#pCurrentBuild) { - // If set, await currently running build - await this.#pCurrentBuild; - } - projectsRequiringBuild = []; + // Enqueue all projects that aren't cached yet + const buildPromises = []; for (const projectName of projectNames) { if (!this.#projectReaders.has(projectName)) { - projectsRequiringBuild.push(projectName); + buildPromises.push(this.#enqueueBuild(projectName)); } } - if (projectsRequiringBuild.length === 0) { - // Projects already built - return this.#getReaderForCachedProjects(projectNames); + // Wait for all builds to complete + if (buildPromises.length > 0) { + await Promise.all(buildPromises); } - this.#pCurrentBuild = this.#projectBuilder.build({ - includedDependencies: projectsRequiringBuild - }).catch((err) => { - this.emit("error", err); - }); - const builtProjects = await this.#pCurrentBuild; - this.#projectBuildFinished(builtProjects); - - // Clear current build promise - this.#pCurrentBuild = null; - return this.#getReaderForCachedProjects(projectNames); } @@ -245,32 +216,106 @@ class BuildServer extends EventEmitter { }); } - // async #getReaderForAllProjects() { - // if (this.#pCurrentBuild) { - // // If set, await initial build - // await this.#pCurrentBuild; - // } - // if (this.#allProjectsReader) { - // return this.#allProjectsReader; - // } - // this.#pCurrentBuild = this.#projectBuilder.build({ - // includedDependencies: ["*"] - // }).catch((err) => { - // this.emit("error", err); - // }); - // const builtProjects = await this.#pCurrentBuild; - // this.#projectBuildFinished(builtProjects); - - // // Clear current build promise - // this.#pCurrentBuild = null; - - // // Create a combined reader for all projects - // this.#allProjectsReader = createReaderCollectionPrioritized({ - // name: "All projects build reader", - // readers: [...this.#projectReaders.values()] - // }); - // return this.#allProjectsReader; - // } + /** + * Enqueues a project for building and returns a promise that resolves with its reader + * + * If the project is already queued, returns the existing promise. Otherwise, creates + * a new promise, adds the project to the pending build queue, and triggers queue processing. + * + * @param {string} projectName Name of the project to enqueue + * @returns {Promise<@ui5/fs/AbstractReader>} Promise that resolves with the project's reader + */ + #enqueueBuild(projectName) { + // If already queued, return existing promise + if (this.#buildQueue.has(projectName)) { + return this.#buildQueue.get(projectName).promise; + } + + log.verbose(`Enqueuing project '${projectName}' for build`); + + // Create new promise for this project + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + // Store promise and resolvers in the queue + this.#buildQueue.set(projectName, {promise, resolve, reject}); + + // Add to pending build requests + this.#pendingBuildRequest.add(projectName); + + // Trigger queue processing if no build is active + if (!this.#activeBuild) { + this.#processBuildQueue().catch((err) => { + this.emit("error", err); + }); + } + + return promise; + } + + /** + * Processes the build queue by batching pending projects and building them + * + * Runs while there are pending build requests. Collects all pending projects, + * builds them in a single batch, resolves/rejects promises for built projects, + * and handles errors with proper isolation. + * + * @returns {Promise} Promise that resolves when queue processing is complete + */ + async #processBuildQueue() { + // Process queue while there are pending requests + while (this.#pendingBuildRequest.size > 0) { + // Collect all pending projects for this batch + const projectsToBuild = Array.from(this.#pendingBuildRequest); + this.#pendingBuildRequest.clear(); + + log.verbose(`Building projects: ${projectsToBuild.join(", ")}`); + + // Set active build to prevent concurrent builds + const buildPromise = this.#activeBuild = this.#projectBuilder.build({ + includedDependencies: projectsToBuild + }); + + try { + const builtProjects = await buildPromise; + this.#projectBuildFinished(builtProjects); + + // Resolve promises for all successfully built projects + for (const projectName of builtProjects) { + const queueEntry = this.#buildQueue.get(projectName); + if (queueEntry) { + const reader = this.#projectReaders.get(projectName); + queueEntry.resolve(reader); + // Only remove from queue if not re-enqueued during build + if (!this.#pendingBuildRequest.has(projectName)) { + log.verbose(`Project '${projectName}' build finished. Removing from build queue.`); + this.#buildQueue.delete(projectName); + } + } + } + } catch (err) { + // Build failed - reject promises for projects that weren't built + for (const projectName of projectsToBuild) { + log.error(`Project '${projectName}' build failed: ${err.message}`); + const queueEntry = this.#buildQueue.get(projectName); + if (queueEntry && !this.#projectReaders.has(projectName)) { + queueEntry.reject(err); + this.#buildQueue.delete(projectName); + this.#pendingBuildRequest.delete(projectName); + } + } + // Re-throw to be handled by caller + throw err; + } finally { + // Clear active build + this.#activeBuild = null; + } + } + } /** * Handles completion of a project build From 27071cb37d2b5c06eb01b8d4fecbaf72c0636754 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 21 Jan 2026 13:13:51 +0100 Subject: [PATCH 107/223] refactor(server): Add error callback and handle in CLI This allows the server to propagate errors to the CLI to be handled there. --- packages/cli/lib/cli/commands/serve.js | 6 +++++- packages/server/lib/server.js | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index 4719e82bf34..218c72ab1d0 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -146,7 +146,10 @@ serve.handler = async function(argv) { serverConfig.cert = cert; } - const {h2, port: actualPort} = await serverServe(graph, serverConfig); + const {promise: pOnError, reject} = Promise.withResolvers(); + const {h2, port: actualPort} = await serverServe(graph, serverConfig, function(err) { + reject(err); + }); const protocol = h2 ? "https" : "http"; let browserUrl = protocol + "://localhost:" + actualPort; @@ -183,6 +186,7 @@ serve.handler = async function(argv) { const {default: open} = await import("open"); open(browserUrl); } + await pOnError; // Await errors that should bubble into the yargs handler }; export default serve; diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index b5c02103b1e..b4768d595e6 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -128,6 +128,7 @@ async function _addSsl({app, key, cert}) { * are send for any requested *.html file * @param {boolean} [options.serveCSPReports=false] Enable CSP reports serving for request url * '/.ui5/csp/csp-reports.json' + * @param {Function} error Error callback. Will be called when an error occurs outside of request handling. * @returns {Promise} Promise resolving once the server is listening. * It resolves with an object containing the port, * h2-flag and a close function, @@ -136,7 +137,7 @@ async function _addSsl({app, key, cert}) { export async function serve(graph, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false -}) { +}, error) { const rootProject = graph.getRoot(); const readers = []; @@ -183,9 +184,7 @@ export async function serve(graph, { }; buildServer.on("error", async (err) => { - log.error(`Error during project build: ${err.message}`); - log.verbose(err.stack); - process.exit(1); + error(err); }); const middlewareManager = new MiddlewareManager({ From a23ba1f979a780dcaca1556d435810cf3cb04a23 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 21 Jan 2026 14:41:48 +0100 Subject: [PATCH 108/223] refactor(project): ProjectBuilder to provide callback on project built --- packages/project/lib/build/ProjectBuilder.js | 59 ++++++++++++------- .../lib/build/cache/ProjectBuildCache.js | 39 ++++++++---- .../lib/build/helpers/ProjectBuildContext.js | 15 +++-- 3 files changed, 75 insertions(+), 38 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index b5afa95c350..2fb7dd0e1ba 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -126,10 +126,10 @@ class ProjectBuilder { async build({ includedDependencies = [], excludedDependencies = [], - }) { + }, projectBuiltCallback) { const requestedProjects = this._determineRequestedProjects( includedDependencies, excludedDependencies); - return await this.#build(requestedProjects); + return await this.#build(requestedProjects, projectBuiltCallback); } /** @@ -175,12 +175,25 @@ class ProjectBuilder { await rmrf(destPath); } - const fsTarget = resourceFactory.createAdapter({ - fsBasePath: destPath, - virBasePath: "/" + let fsTarget; + if (!process.env.UI5_BUILD_NO_WRITE_DEST) { + fsTarget = resourceFactory.createAdapter({ + fsBasePath: destPath, + virBasePath: "/" + }); + } + const pWrites = []; + await this.#build(requestedProjects, (projectName, project, projectBuildContext) => { + if (!fsTarget) { + // Nothing to write to + return; + } + // Only write requested projects to target + // (excluding dependencies that were required to be built, but not requested) + this.#log.verbose(`Writing out files for project ${projectName}...`); + pWrites.push(this._writeResults(projectBuildContext, fsTarget)); }); - - await this.#build(requestedProjects, fsTarget); + await Promise.all(pWrites); } _determineRequestedProjects(includedDependencies, excludedDependencies, dependencyIncludes) { @@ -209,7 +222,7 @@ class ProjectBuilder { return requestedProjects; } - async #build(requestedProjects, fsTarget) { + async #build(requestedProjects, projectBuiltCallback) { if (this.#buildIsRunning) { throw new Error("A build is already running"); } @@ -250,7 +263,7 @@ class ProjectBuilder { const cleanupSigHooks = this._registerCleanupSigHooks(); try { const startTime = process.hrtime(); - const pWrites = []; + const pCacheWrites = []; while (queue.length) { const projectBuildContext = queue.shift(); const project = projectBuildContext.getProject(); @@ -262,27 +275,31 @@ class ProjectBuilder { if (alreadyBuilt.includes(projectName)) { this.#log.skipProjectBuild(projectName, projectType); } else { - if (await projectBuildContext.prepareProjectBuildAndValidateCache(true)) { + let changedPaths = await projectBuildContext.prepareProjectBuildAndValidateCache(); + if (changedPaths) { this.#log.skipProjectBuild(projectName, projectType); alreadyBuilt.push(projectName); } else { - await this._buildProject(projectBuildContext); + changedPaths = await this._buildProject(projectBuildContext); + } + if (changedPaths.length) { + // Propagate resource changes to following projects + for (const pbc of queue) { + pbc.dependencyResourcesChanged(changedPaths); + } } } - if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_CACHE) { - this.#log.verbose(`Triggering cache update for project ${projectName}...`); - pWrites.push(projectBuildContext.getBuildCache().writeCache()); + if (projectBuiltCallback && requestedProjects.includes(projectName)) { + projectBuiltCallback(projectName, project, projectBuildContext); } - if (fsTarget && requestedProjects.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_DEST) { - // Only write requested projects to target - // (excluding dependencies that were required to be built, but not requested) - this.#log.verbose(`Writing out files for project ${projectName}...`); - pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_CACHE) { + this.#log.verbose(`Triggering cache update for project ${projectName}...`); + pCacheWrites.push(projectBuildContext.getBuildCache().writeCache()); } } - await Promise.all(pWrites); + await Promise.all(pCacheWrites); this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); } catch (err) { this.#log.error(`Build failed`); @@ -304,7 +321,7 @@ class ProjectBuilder { const changedResources = await projectBuildContext.buildProject(); this.#log.endProjectBuild(projectName, projectType); - return {changedResources}; + return changedResources; } _createProjectFilter({ diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index bb3f6f52c7a..98561518f88 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -95,6 +95,20 @@ export default class ProjectBuildCache { return cache; } + async refreshDependencyIndices(dependencyReader) { + if (this.#cacheState === CACHE_STATES.EMPTY) { + // No need to update indices for empty cache + return false; + } + const updateStart = performance.now(); + await this.#refreshDependencyIndices(dependencyReader); + if (log.isLevelEnabled("perf")) { + log.perf( + `Refreshed dependency indices for project ${this.#project.getName()} ` + + `in ${(performance.now() - updateStart).toFixed(2)} ms`); + } + } + /** * Sets the dependency reader for accessing dependency resources * @@ -103,28 +117,21 @@ export default class ProjectBuildCache { * * @public * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources - * @param {boolean} [forceDependencyUpdate=false] Force update of dependency indices * @returns {Promise} * Undefined if no cache has been found, false if cache is empty, * or an array of changed resource paths */ - async prepareProjectBuildAndValidateCache(dependencyReader, forceDependencyUpdate = false) { + async prepareProjectBuildAndValidateCache(dependencyReader) { this.#currentProjectReader = this.#project.getReader(); this.#currentDependencyReader = dependencyReader; + if (this.#cacheState === CACHE_STATES.INITIALIZING) { + throw new Error(`Project ${this.#project.getName()} build cache unexpectedly not yet initialized.`); + } if (this.#cacheState === CACHE_STATES.EMPTY) { log.verbose(`Project ${this.#project.getName()} has empty cache, skipping change processing.`); return false; } - if (forceDependencyUpdate) { - const updateStart = performance.now(); - await this.#refreshDependencyIndices(dependencyReader); - if (log.isLevelEnabled("perf")) { - log.perf( - `Refreshed dependency indices for project ${this.#project.getName()} ` + - `in ${(performance.now() - updateStart).toFixed(2)} ms`); - } - } const flushStart = performance.now(); await this.#flushPendingChanges(); if (log.isLevelEnabled("perf")) { @@ -190,7 +197,7 @@ export default class ProjectBuildCache { async #refreshDependencyIndices(dependencyReader) { let depIndicesChanged = false; await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { - const changed = await taskCache.refreshDependencyIndices(this.#currentDependencyReader); + const changed = await taskCache.refreshDependencyIndices(dependencyReader); if (changed) { depIndicesChanged = true; } @@ -198,6 +205,9 @@ export default class ProjectBuildCache { if (depIndicesChanged) { // Relevant resources have changed, mark the cache as dirty this.#cacheState = CACHE_STATES.DIRTY; + } else if (this.#cacheState === CACHE_STATES.INITIALIZING) { + // Dependency index is up-to-date. Set cache state to initialized if it was still initializing (not dirty) + this.#cacheState = CACHE_STATES.INITIALIZED; } // Reset pending dependency changes since indices are fresh now anyways this.#changedDependencyResourcePaths = []; @@ -796,9 +806,12 @@ export default class ProjectBuildCache { } if (changedPaths.length) { + // Relevant resources have changed, mark the cache as dirty this.#cacheState = CACHE_STATES.DIRTY; } else { - this.#cacheState = CACHE_STATES.INITIALIZED; + // Source index is up-to-date, awaiting dependency indices validation + // Status remains at initializing + this.#cacheState = CACHE_STATES.INITIALIZING; } this.#sourceIndex = resourceIndex; this.#cachedSourceSignature = resourceIndex.getSignature(); diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 4d4e91c762f..83afa453183 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -12,6 +12,8 @@ import ProjectBuildCache from "../cache/ProjectBuildCache.js"; * @memberof @ui5/project/build/helpers */ class ProjectBuildContext { + #initialPrepareRun = true; + /** * Creates a new ProjectBuildContext instance * @@ -258,18 +260,23 @@ class ProjectBuildContext { * Creates a dependency reader and validates the cache state against current resources. * Must be called before buildProject(). * - * @param {boolean} initialBuild Whether this is the initial build (forces dependency index update) * @returns {Promise} * Undefined if no cache was found, false if cache is empty, * or an array of changed resource paths since the last build */ - async prepareProjectBuildAndValidateCache(initialBuild) { + async prepareProjectBuildAndValidateCache() { const depReader = await this.getTaskRunner().getDependenciesReader( await this.getTaskRunner().getRequiredDependencies(), true, // Force creation of new reader since project readers might have changed during their (re-)build ); - this._currentDependencyReader = depReader; - return await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader, initialBuild); + if (this.#initialPrepareRun) { + this.#initialPrepareRun = false; + // If this is the first build of the project, the dependency indices must be refreshed + // Later builds of the same project during the same overall build can reuse the existing indices + // (they will be updated based on input via #dependencyResourcesChanged) + await this.getBuildCache().refreshDependencyIndices(depReader); + } + return await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader); } /** From 8ff3e3c6c44078f19ef40a1c7fe64bb89012a538 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 21 Jan 2026 14:53:23 +0100 Subject: [PATCH 109/223] refactor(project): Do not always include root project in build --- packages/project/lib/build/ProjectBuilder.js | 24 ++++++++++---------- packages/project/lib/graph/ProjectGraph.js | 4 +++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 2fb7dd0e1ba..b4eb7abcb54 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -125,10 +125,11 @@ class ProjectBuilder { } async build({ + includeRootProject = true, includedDependencies = [], excludedDependencies = [], }, projectBuiltCallback) { const requestedProjects = this._determineRequestedProjects( - includedDependencies, excludedDependencies); + includeRootProject, includedDependencies, excludedDependencies); return await this.#build(requestedProjects, projectBuiltCallback); } @@ -168,7 +169,7 @@ class ProjectBuilder { } this.#log.info(`Target directory: ${destPath}`); const requestedProjects = this._determineRequestedProjects( - includedDependencies, excludedDependencies, dependencyIncludes); + true, includedDependencies, excludedDependencies, dependencyIncludes); if (cleanDest) { this.#log.info(`Cleaning target directory...`); @@ -196,10 +197,11 @@ class ProjectBuilder { await Promise.all(pWrites); } - _determineRequestedProjects(includedDependencies, excludedDependencies, dependencyIncludes) { + _determineRequestedProjects(includeRootProject, includedDependencies, excludedDependencies, dependencyIncludes) { // Get project filter function based on include/exclude params // (also logs some info to console) const filterProject = this._createProjectFilter({ + includeRootProject, explicitIncludes: includedDependencies, explicitExcludes: excludedDependencies, dependencyIncludes @@ -227,15 +229,12 @@ class ProjectBuilder { throw new Error("A build is already running"); } this.#buildIsRunning = true; - const rootProjectName = this._graph.getRoot().getName(); - this.#log.info(`Preparing build for project ${rootProjectName}`); - // this._flushResourceChanges(); const projectBuildContexts = await this._buildContext.getRequiredProjectContexts(requestedProjects); // Create build queue based on graph depth-first search to ensure correct build order const queue = []; - const builtProjects = []; + const processedProjectNames = []; for (const {project} of this._graph.traverseDependenciesDepthFirst(true)) { const projectName = project.getName(); const projectBuildContext = projectBuildContexts.get(projectName); @@ -244,7 +243,7 @@ class ProjectBuilder { // => This project needs to be built or, in case it has already // been built, it's build result needs to be written out (if requested) queue.push(projectBuildContext); - builtProjects.push(projectName); + processedProjectNames.push(projectName); } } @@ -309,7 +308,7 @@ class ProjectBuilder { await this._executeCleanupTasks(); } this.#buildIsRunning = false; - return builtProjects; + return processedProjectNames; } async _buildProject(projectBuildContext) { @@ -325,6 +324,7 @@ class ProjectBuilder { } _createProjectFilter({ + includeRootProject = true, dependencyIncludes, explicitIncludes, explicitExcludes @@ -339,7 +339,7 @@ class ProjectBuilder { if (includedDependencies.length) { if (includedDependencies.length === this._graph.getSize() - 1) { - this.#log.info(` Including all dependencies`); + this.#log.info(` Requested all dependencies`); } else { this.#log.info(` Requested dependencies:\n + ${includedDependencies.join("\n + ")}`); } @@ -355,8 +355,8 @@ class ProjectBuilder { dep.test(projectName) : dep === projectName); } - if (projectName === rootProjectName) { - // Always include the root project + if (includeRootProject && projectName === rootProjectName) { + // Include root project return true; } diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index a738eede4a2..f3d2ccc0384 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -750,6 +750,7 @@ class ProjectGraph { } async serve({ + initialBuildRootProject = false, initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], @@ -777,7 +778,8 @@ class ProjectGraph { const { default: BuildServer } = await import("../build/BuildServer.js"); - return new BuildServer(this, builder, initialBuildIncludedDependencies, initialBuildExcludedDependencies); + return new BuildServer(this, builder, + initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); } /** From dca95b57e3d887969e47a6b036ca7887e453b37d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 21 Jan 2026 15:58:14 +0100 Subject: [PATCH 110/223] refactor(project): Refactor BuildServer init, add tests --- packages/project/lib/build/BuildServer.js | 40 +++- .../project/lib/build/helpers/WatchHandler.js | 11 +- .../lib/build/ProjectBuilder.integration.js | 2 +- .../lib/build/ProjectServer.integration.js | 226 ++++++++++++++++++ 4 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 packages/project/test/lib/build/ProjectServer.integration.js diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 2018c4cc98c..5949a9a1d3d 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -29,6 +29,8 @@ const log = getLogger("build:BuildServer"); class BuildServer extends EventEmitter { #graph; #projectBuilder; + #watchHandler; + #rootProjectName; #buildQueue = new Map(); #pendingBuildRequest = new Set(); #activeBuild = null; @@ -46,12 +48,17 @@ class BuildServer extends EventEmitter { * @public * @param {@ui5/project/graph/ProjectGraph} graph Project graph containing all projects * @param {@ui5/project/build/ProjectBuilder} projectBuilder Builder instance for executing builds + * @param {boolean} initialBuildRootProject Whether to build the root project in the initial build * @param {string[]} initialBuildIncludedDependencies Project names to include in initial build * @param {string[]} initialBuildExcludedDependencies Project names to exclude from initial build */ - constructor(graph, projectBuilder, initialBuildIncludedDependencies, initialBuildExcludedDependencies) { + constructor( + graph, projectBuilder, + initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies + ) { super(); this.#graph = graph; + this.#rootProjectName = graph.getRoot().getName(); this.#projectBuilder = projectBuilder; this.#allReader = new BuildReader("Build Server: All Projects Reader", Array.from(this.#graph.getProjects()), @@ -68,6 +75,9 @@ class BuildServer extends EventEmitter { this.#getReaderForProject.bind(this), this.#getReaderForProjects.bind(this)); + if (initialBuildRootProject) { + this.#pendingBuildRequest.add(this.#rootProjectName); + } if (initialBuildIncludedDependencies.length > 0) { // Enqueue initial build dependencies for (const projectName of initialBuildIncludedDependencies) { @@ -81,6 +91,7 @@ class BuildServer extends EventEmitter { } const watchHandler = new WatchHandler(); + this.#watchHandler = watchHandler; const allProjects = graph.getProjects(); watchHandler.watch(allProjects).catch((err) => { // Error during watch setup @@ -89,11 +100,14 @@ class BuildServer extends EventEmitter { watchHandler.on("error", (err) => { this.emit("error", err); }); - watchHandler.on("sourcesChanged", (changes) => { - // Inform project builder - - log.verbose("Source changes detected: ", changes); + watchHandler.on("change", (eventType, filePath, project) => { + log.verbose(`Source change detected: ${eventType} ${filePath} in project '${project.getName()}'`); + // TODO: Abort any active build + }); + watchHandler.on("batchedChanges", (changes) => { + log.verbose(`Received batched source changes for projects: ${[...changes.keys()].join(", ")}`); + // Inform project builder const affectedProjects = this.#projectBuilder.resourcesChanged(changes); for (const projectName of affectedProjects) { @@ -111,6 +125,14 @@ class BuildServer extends EventEmitter { }); } + async destroy() { + await this.#watchHandler.destroy(); + if (this.#activeBuild) { + // Await active build to finish + await this.#activeBuild; + } + } + /** * Gets a reader for all projects (root and dependencies) * @@ -271,13 +293,21 @@ class BuildServer extends EventEmitter { while (this.#pendingBuildRequest.size > 0) { // Collect all pending projects for this batch const projectsToBuild = Array.from(this.#pendingBuildRequest); + let buildRootProject = false; + if (projectsToBuild.includes(this.#rootProjectName)) { + buildRootProject = true; + } this.#pendingBuildRequest.clear(); log.verbose(`Building projects: ${projectsToBuild.join(", ")}`); // Set active build to prevent concurrent builds const buildPromise = this.#activeBuild = this.#projectBuilder.build({ + includeRootProject: buildRootProject, includedDependencies: projectsToBuild + }, (projectName, project) => { + // Project has been built and result can be served + // TODO: Immediately resolve pending promises here instead of waiting for full build to finish }); try { diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 72ea95b65bc..23cd8d56133 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -32,7 +32,13 @@ class WatchHandler extends EventEmitter { await watcher.close(); }); watcher.on("all", (event, filePath) => { - this.#handleWatchEvents(event, filePath, project); + if (event === "addDir") { + // Ignore directory creation events + return; + } + this.#handleWatchEvents(event, filePath, project).catch((err) => { + this.emit("error", err); + }); }); const {promise, resolve: ready} = Promise.withResolvers(); readyPromises.push(promise); @@ -56,6 +62,7 @@ class WatchHandler extends EventEmitter { async #handleWatchEvents(eventType, filePath, project) { log.verbose(`File changed: ${eventType} ${filePath}`); await this.#fileChanged(project, filePath); + this.emit("change", eventType, filePath, project); } #fileChanged(project, filePath) { @@ -88,7 +95,7 @@ class WatchHandler extends EventEmitter { this.#sourceChanges = new Map(); try { - this.emit("sourcesChanged", sourceChanges); + this.emit("batchedChanges", sourceChanges); } catch (err) { this.emit("error", err); } diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 0aab8ebbaf0..338393ddbd3 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -323,7 +323,7 @@ function getFixturePath(fixtureName) { } function getTmpPath(folderName) { - return fileURLToPath(new URL(`../../tmp/${folderName}`, import.meta.url)); + return fileURLToPath(new URL(`../../tmp/ProjectBuilder/${folderName}`, import.meta.url)); } async function rmrf(dirPath) { diff --git a/packages/project/test/lib/build/ProjectServer.integration.js b/packages/project/test/lib/build/ProjectServer.integration.js new file mode 100644 index 00000000000..0eed9db1d26 --- /dev/null +++ b/packages/project/test/lib/build/ProjectServer.integration.js @@ -0,0 +1,226 @@ +import test from "ava"; +import sinonGlobal from "sinon"; +import {fileURLToPath} from "node:url"; +import {setTimeout} from "node:timers/promises"; +import fs from "node:fs/promises"; +import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; +import {setLogLevel} from "@ui5/logger"; + +// Ensures that all logging code paths are tested +setLogLevel("silly"); + +test.beforeEach((t) => { + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + + t.context.logEventStub = sinon.stub(); + t.context.buildMetadataEventStub = sinon.stub(); + t.context.projectBuildMetadataEventStub = sinon.stub(); + t.context.buildStatusEventStub = sinon.stub(); + t.context.projectBuildStatusEventStub = sinon.stub(); + + process.on("ui5.log", t.context.logEventStub); + process.on("ui5.build-metadata", t.context.buildMetadataEventStub); + process.on("ui5.project-build-metadata", t.context.projectBuildMetadataEventStub); + process.on("ui5.build-status", t.context.buildStatusEventStub); + process.on("ui5.project-build-status", t.context.projectBuildStatusEventStub); +}); + +test.afterEach.always(async (t) => { + await t.context.fixtureTester.teardown(); + t.context.sinon.restore(); + delete process.env.UI5_DATA_DIR; + + process.off("ui5.log", t.context.logEventStub); + process.off("ui5.build-metadata", t.context.buildMetadataEventStub); + process.off("ui5.project-build-metadata", t.context.projectBuildMetadataEventStub); + process.off("ui5.build-status", t.context.buildStatusEventStub); + process.off("ui5.project-build-status", t.context.projectBuildStatusEventStub); +}); + +test.serial("Serve application.a, request application resource", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + t.context.fixtureTester = fixtureTester; + + // #1 request with empty cache + await fixtureTester.serveProject(); + await fixtureTester.requestResource("/test.js", { + projects: { + "application.a": {} + } + }); + + // #2 request with cache + await fixtureTester.requestResource("/test.js", { + projects: {} + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added");\n`); + + // #3 request with cache and changes + const res = await fixtureTester.requestResource("/test.js", { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + }); + + // Check whether the changed file is in the destPath + const servedFileContent = await res.toString(); + t.true(servedFileContent.includes(`test("line added");`), "Resource contains changed file content"); +}); + +test.serial("Serve application.a, request library resource", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + t.context.fixtureTester = fixtureTester; + + // #1 request with empty cache + await fixtureTester.serveProject(); + await fixtureTester.requestResource("/resources/library/a/.library", { + projects: { + "library.a": {} + } + }); + + // #2 request with cache + await fixtureTester.requestResource("/resources/library/a/.library", { + projects: {} + }); + + // Change a source file in library.a + const changedFilePath = `${fixtureTester.fixturePath}/node_modules/collection/library.a/src/library/a/.library`; + await fs.appendFile(changedFilePath, `\n\n`); + + await setTimeout(500); // Wait for the file watcher to detect and propagate the change + + // #3 request with cache and changes + const res = await fixtureTester.requestResource("/resources/library/a/.library", { + projects: { + "library.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "minify", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceBuildtime", + "replaceCopyright", + "replaceVersion", + ] + } + } + }); + + // Check whether the changed file is in the destPath + const servedFileContent = await res.getString(); + t.true(servedFileContent.includes(``), "Resource contains changed file content"); +}); + +function getFixturePath(fixtureName) { + return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); +} + +function getTmpPath(folderName) { + return fileURLToPath(new URL(`../../tmp/ProjectServer/${folderName}`, import.meta.url)); +} + +async function rmrf(dirPath) { + return fs.rm(dirPath, {recursive: true, force: true}); +} + +class FixtureTester { + constructor(t, fixtureName) { + this._t = t; + this._sinon = t.context.sinon; + this._fixtureName = fixtureName; + this._initialized = false; + + // Public + this.fixturePath = getTmpPath(fixtureName); + } + + async _initialize() { + if (this._initialized) { + return; + } + process.env.UI5_DATA_DIR = getTmpPath(`${this._fixtureName}/.ui5`); + await rmrf(this.fixturePath); // Clean up any previous test runs + await fs.cp(getFixturePath(this._fixtureName), this.fixturePath, {recursive: true}); + this._initialized = true; + } + + async teardown() { + if (this._buildServer) { + await this._buildServer.destroy(); + } + } + + async serveProject({graphConfig = {}, config = {}} = {}) { + await this._initialize(); + + const graph = await graphFromPackageDependencies({ + ...graphConfig, + cwd: this.fixturePath, + }); + + // Execute the build + this._buildServer = await graph.serve(config); + this._buildServer.on("error", (err) => { + this._t.fail(`Build server error: ${err.message}`); + }); + this._reader = this._buildServer.getReader(); + } + + async requestResource(resource, assertions = {}) { + this._sinon.resetHistory(); + const res = await this._reader.byPath(resource); + // Apply assertions if provided + if (assertions) { + this._assertBuild(assertions); + } + return res; + } + + _assertBuild(assertions) { + const {projects = {}} = assertions; + const eventArgs = this._t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + + const projectsInOrder = []; + const seenProjects = new Set(); + const tasksByProject = {}; + + for (const event of eventArgs) { + if (!seenProjects.has(event.projectName)) { + projectsInOrder.push(event.projectName); + seenProjects.add(event.projectName); + } + if (!tasksByProject[event.projectName]) { + tasksByProject[event.projectName] = {executed: [], skipped: []}; + } + if (event.status === "task-skip") { + tasksByProject[event.projectName].skipped.push(event.taskName); + } else if (event.status === "task-start") { + tasksByProject[event.projectName].executed.push(event.taskName); + } + } + + // Assert projects built in order + const expectedProjects = Object.keys(projects); + this._t.deepEqual(projectsInOrder, expectedProjects); + + // Assert skipped tasks per project + for (const [projectName, expectedSkipped] of Object.entries(projects)) { + const skippedTasks = expectedSkipped.skippedTasks || []; + const actualSkipped = (tasksByProject[projectName]?.skipped || []).sort(); + const expectedArray = skippedTasks.sort(); + this._t.deepEqual(actualSkipped, expectedArray); + } + } +} From 886154941cd2fec7f724219583cb1e28e8a1a821 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 23 Jan 2026 09:31:12 +0100 Subject: [PATCH 111/223] refactor(project): Minor ProjectBuildCache and ProjectBuildContext refactoring --- packages/project/lib/build/ProjectBuilder.js | 14 +++----- .../lib/build/cache/ProjectBuildCache.js | 18 +++++----- .../project/lib/build/helpers/BuildContext.js | 2 +- .../lib/build/helpers/ProjectBuildContext.js | 35 +++++++++++++++---- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index b4eb7abcb54..be8c07b41d8 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -274,18 +274,12 @@ class ProjectBuilder { if (alreadyBuilt.includes(projectName)) { this.#log.skipProjectBuild(projectName, projectType); } else { - let changedPaths = await projectBuildContext.prepareProjectBuildAndValidateCache(); - if (changedPaths) { + const usesCache = await projectBuildContext.prepareProjectBuildAndValidateCache(); + if (usesCache) { this.#log.skipProjectBuild(projectName, projectType); alreadyBuilt.push(projectName); } else { - changedPaths = await this._buildProject(projectBuildContext); - } - if (changedPaths.length) { - // Propagate resource changes to following projects - for (const pbc of queue) { - pbc.dependencyResourcesChanged(changedPaths); - } + await this._buildProject(projectBuildContext); } } @@ -295,7 +289,7 @@ class ProjectBuilder { if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_CACHE) { this.#log.verbose(`Triggering cache update for project ${projectName}...`); - pCacheWrites.push(projectBuildContext.getBuildCache().writeCache()); + pCacheWrites.push(projectBuildContext.writeBuildCache()); } } await Promise.all(pCacheWrites); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 98561518f88..81c2872b83e 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -17,7 +17,7 @@ export const CACHE_STATES = Object.freeze({ EMPTY: "empty", STALE: "stale", FRESH: "fresh", - DIRTY: "dirty", + INVALIDATED: "invalidated", }); /** @@ -177,8 +177,8 @@ export default class ProjectBuildCache { } if (sourceIndexChanged || depIndicesChanged) { - // Relevant resources have changed, mark the cache as dirty - this.#cacheState = CACHE_STATES.DIRTY; + // Relevant resources have changed, mark the cache as invalidated + this.#cacheState = CACHE_STATES.INVALIDATED; } else { log.verbose(`No relevant resource changes detected for project ${this.#project.getName()}`); } @@ -203,10 +203,10 @@ export default class ProjectBuildCache { } })); if (depIndicesChanged) { - // Relevant resources have changed, mark the cache as dirty - this.#cacheState = CACHE_STATES.DIRTY; + // Relevant resources have changed, mark the cache as invalidated + this.#cacheState = CACHE_STATES.INVALIDATED; } else if (this.#cacheState === CACHE_STATES.INITIALIZING) { - // Dependency index is up-to-date. Set cache state to initialized if it was still initializing (not dirty) + // Dependency index is up-to-date. Set cache state to initialized (if it was still initializing) this.#cacheState = CACHE_STATES.INITIALIZED; } // Reset pending dependency changes since indices are fresh now anyways @@ -241,7 +241,7 @@ export default class ProjectBuildCache { return []; } - if (![CACHE_STATES.STALE, CACHE_STATES.DIRTY, CACHE_STATES.INITIALIZED].includes(this.#cacheState)) { + if (![CACHE_STATES.STALE, CACHE_STATES.INVALIDATED, CACHE_STATES.INITIALIZED].includes(this.#cacheState)) { log.verbose(`Project ${this.#project.getName()} cache state is ${this.#cacheState}, ` + `skipping result cache validation.`); return; @@ -806,8 +806,8 @@ export default class ProjectBuildCache { } if (changedPaths.length) { - // Relevant resources have changed, mark the cache as dirty - this.#cacheState = CACHE_STATES.DIRTY; + // Relevant resources have changed, mark the cache as invalidated + this.#cacheState = CACHE_STATES.INVALIDATED; } else { // Source index is up-to-date, awaiting dependency indices validation // Status remains at initializing diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 9410da4a524..56bfb2c8c83 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -155,7 +155,7 @@ class BuildContext { /** * - * @param {Map>} resourceChanges + * @param {Map>} resourceChanges Mapping project name to changed resource paths * @returns {Set} Names of projects potentially affected by the resource changes */ propagateResourceChanges(resourceChanges) { diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 83afa453183..6ae6d988967 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -260,9 +260,8 @@ class ProjectBuildContext { * Creates a dependency reader and validates the cache state against current resources. * Must be called before buildProject(). * - * @returns {Promise} - * Undefined if no cache was found, false if cache is empty, - * or an array of changed resource paths since the last build + * @returns {Promise} + * True if a valid cache was found and is being used. False otherwise (indicating a build is required). */ async prepareProjectBuildAndValidateCache() { const depReader = await this.getTaskRunner().getDependenciesReader( @@ -276,7 +275,13 @@ class ProjectBuildContext { // (they will be updated based on input via #dependencyResourcesChanged) await this.getBuildCache().refreshDependencyIndices(depReader); } - return await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader); + const boolOrChangedPaths = await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader); + if (Array.isArray(boolOrChangedPaths)) { + // Cache can be used, but some resources have changed + // Propagate changed paths to dependents + this.propagateResourceChanges(boolOrChangedPaths); + } + return !!boolOrChangedPaths; } /** @@ -284,11 +289,11 @@ class ProjectBuildContext { * * Executes all configured build tasks for the project using the task runner. * Must be called after prepareProjectBuildAndValidateCache(). - * - * @returns {Promise} List of changed resource paths since the last build */ async buildProject() { - return await this.getTaskRunner().runTasks(); + const changedPaths = await this.getTaskRunner().runTasks(); + // Propagate changed paths to dependents + this.propagateResourceChanges(changedPaths); } /** * Informs the build cache about changed project source resources @@ -314,6 +319,18 @@ class ProjectBuildContext { return this._buildCache.dependencyResourcesChanged(changedPaths); } + propagateResourceChanges(changedPaths) { + if (!changedPaths.length) { + return; + } + for (const {project: dep} of this._buildContext.getGraph().traverseDependents(this._project.getName())) { + const projectBuildContext = this._buildContext.getBuildContext(dep.getName()); + if (projectBuildContext) { + projectBuildContext.dependencyResourcesChanged(changedPaths); + } + } + } + /** * Gets the build manifest if available and compatible * @@ -368,6 +385,10 @@ class ProjectBuildContext { return this._buildCache; } + async writeBuildCache() { + await this._buildCache.writeCache(); + } + /** * Gets the build signature for this project * From 9c369097aa94bb2dee5fc2d58abd478a23ef2297 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 23 Jan 2026 14:18:06 +0100 Subject: [PATCH 112/223] refactor(project): Handle abort signal in ProjectBuilder et al. --- packages/project/lib/build/ProjectBuilder.js | 23 +++++++++++++------ packages/project/lib/build/TaskRunner.js | 4 +++- .../lib/build/helpers/ProjectBuildContext.js | 6 +++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index be8c07b41d8..b9dc279782e 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -121,16 +121,20 @@ class ProjectBuilder { } resourcesChanged(changes) { + if (this.#buildIsRunning) { + throw new Error(`Unable to safely propagate resource changes. Build is currently running.`); + } return this._buildContext.propagateResourceChanges(changes); } async build({ includeRootProject = true, includedDependencies = [], excludedDependencies = [], + signal, }, projectBuiltCallback) { const requestedProjects = this._determineRequestedProjects( includeRootProject, includedDependencies, excludedDependencies); - return await this.#build(requestedProjects, projectBuiltCallback); + return await this.#build(requestedProjects, projectBuiltCallback, signal); } /** @@ -224,12 +228,12 @@ class ProjectBuilder { return requestedProjects; } - async #build(requestedProjects, projectBuiltCallback) { + async #build(requestedProjects, projectBuiltCallback, signal) { if (this.#buildIsRunning) { throw new Error("A build is already running"); } this.#buildIsRunning = true; - + this.#log.info(`Preparing build for projects: ${requestedProjects.join(", ")}`); const projectBuildContexts = await this._buildContext.getRequiredProjectContexts(requestedProjects); // Create build queue based on graph depth-first search to ensure correct build order @@ -264,6 +268,7 @@ class ProjectBuilder { const startTime = process.hrtime(); const pCacheWrites = []; while (queue.length) { + signal?.throwIfAborted(); const projectBuildContext = queue.shift(); const project = projectBuildContext.getProject(); const projectName = project.getName(); @@ -295,23 +300,27 @@ class ProjectBuilder { await Promise.all(pCacheWrites); this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); } catch (err) { - this.#log.error(`Build failed`); + if (err.name === "AbortError") { + this.#log.info(`Build aborted. Reason: ${err.message}`); + } else { + this.#log.error(`Build failed`); + } throw err; } finally { this._deregisterCleanupSigHooks(cleanupSigHooks); await this._executeCleanupTasks(); + this.#buildIsRunning = false; } - this.#buildIsRunning = false; return processedProjectNames; } - async _buildProject(projectBuildContext) { + async _buildProject(projectBuildContext, signal) { const project = projectBuildContext.getProject(); const projectName = project.getName(); const projectType = project.getType(); this.#log.startProjectBuild(projectName, projectType); - const changedResources = await projectBuildContext.buildProject(); + const changedResources = await projectBuildContext.buildProject(signal); this.#log.endProjectBuild(projectName, projectType); return changedResources; diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index a93ed299e93..95a09c02c2f 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -86,9 +86,10 @@ class TaskRunner { /** * Takes a list of tasks which should be executed from the available task list of the current builder * + * @param {AbortSignal} [signal] Abort signal * @returns {Promise} Resolves with list of changed resources since the last build */ - async runTasks() { + async runTasks(signal) { await this._initTasks(); // Ensure cached dependencies reader is initialized and up-to-date (TODO: improve this lifecycle) @@ -113,6 +114,7 @@ class TaskRunner { this._log.setTasks(allTasks); this._buildCache.setTasks(allTasks); for (const taskName of allTasks) { + signal?.throwIfAborted(); const taskFunction = this._tasks[taskName].task; if (typeof taskFunction === "function") { diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 6ae6d988967..35e68f7fa36 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -289,9 +289,11 @@ class ProjectBuildContext { * * Executes all configured build tasks for the project using the task runner. * Must be called after prepareProjectBuildAndValidateCache(). + * + * @param {AbortSignal} [signal] Abort signal */ - async buildProject() { - const changedPaths = await this.getTaskRunner().runTasks(); + async buildProject(signal) { + const changedPaths = await this.getTaskRunner().runTasks(signal); // Propagate changed paths to dependents this.propagateResourceChanges(changedPaths); } From a279db4baf2dab2525205e6f266c49a064fc3cdc Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 23 Jan 2026 15:05:35 +0100 Subject: [PATCH 113/223] refactor(project): Refactor BuildServer queue Handle reader states and builds per project --- packages/project/lib/build/BuildReader.js | 61 ++-- packages/project/lib/build/BuildServer.js | 332 +++++++++++------- .../lib/build/cache/ProjectBuildCache.js | 23 +- .../project/lib/build/helpers/BuildContext.js | 8 +- ...egration.js => BuildServer.integration.js} | 4 +- 5 files changed, 266 insertions(+), 162 deletions(-) rename packages/project/test/lib/build/{ProjectServer.integration.js => BuildServer.integration.js} (98%) diff --git a/packages/project/lib/build/BuildReader.js b/packages/project/lib/build/BuildReader.js index 5118fb7bbb0..51abd136ee3 100644 --- a/packages/project/lib/build/BuildReader.js +++ b/packages/project/lib/build/BuildReader.js @@ -13,9 +13,9 @@ import AbstractReader from "@ui5/fs/AbstractReader"; class BuildReader extends AbstractReader { #projects; #projectNames; + #applicationProjectName; #namespaces = new Map(); - #getReaderForProject; - #getReaderForProjects; + #buildServerInterface; /** * Creates a new BuildReader instance @@ -23,16 +23,14 @@ class BuildReader extends AbstractReader { * @public * @param {string} name Name of the reader * @param {Array<@ui5/project/specifications/Project>} projects Array of projects to read from - * @param {Function} getReaderForProject Function that returns a reader for a single project by name - * @param {Function} getReaderForProjects Function that returns a combined reader for multiple project names + * @param {object} buildServerInterface Function that returns a reader for a single project by name * @throws {Error} If multiple projects share the same namespace */ - constructor(name, projects, getReaderForProject, getReaderForProjects) { + constructor(name, projects, buildServerInterface) { super(name); this.#projects = projects; this.#projectNames = projects.map((p) => p.getName()); - this.#getReaderForProject = getReaderForProject; - this.#getReaderForProjects = getReaderForProjects; + this.#buildServerInterface = buildServerInterface; for (const project of projects) { const ns = project.getNamespace(); @@ -44,6 +42,10 @@ class BuildReader extends AbstractReader { } this.#namespaces.set(ns, project.getName()); } + + if (project.getType() === "application") { + this.#applicationProjectName = project.getName(); + } } } @@ -57,7 +59,7 @@ class BuildReader extends AbstractReader { * @returns {Promise>} Promise resolving to list of resources */ async byGlob(...args) { - const reader = await this.#getReaderForProjects(this.#projectNames); + const reader = await this.#buildServerInterface.getReaderForProjects(this.#projectNames); return reader.byGlob(...args); } @@ -77,7 +79,7 @@ class BuildReader extends AbstractReader { let res = await reader.byPath(virPath, ...args); if (!res) { // Fallback to unspecified projects - const allReader = await this.#getReaderForProjects(this.#projectNames); + const allReader = await this.#buildServerInterface.getReaderForProjects(this.#projectNames); res = await allReader.byPath(virPath, ...args); } return res; @@ -94,23 +96,40 @@ class BuildReader extends AbstractReader { * @returns {Promise<@ui5/fs/AbstractReader>} Promise resolving to appropriate reader */ async _getReaderForResource(virPath) { - let reader; if (this.#projects.length === 1) { // Filtering on a single project (typically the root project) - reader = await this.#getReaderForProject(this.#projectNames[0]); - } else { - // Determine project for resource path - const projects = this._getProjectsForResourcePath(virPath); - if (projects.length) { - reader = await this.#getReaderForProjects(projects); - } else { - // Unable to determine project for resource - // Request reader for all projects - reader = await this.#getReaderForProjects(this.#projectNames); + return await this.#buildServerInterface.getReaderForProject(this.#projectNames[0]); + } + // Determine project for resource path + const projects = this._getProjectsForResourcePath(virPath); + if (projects.length) { + return await this.#buildServerInterface.getReaderForProjects(projects); + } + + // Unable to determine project for resource using path + // Fallback 1: Try to find resource in cached readers (if available) to identify the relevant project + const cachedReader = this.#buildServerInterface.getCachedReadersForProjects(this.#projectNames); + if (cachedReader) { + const res = await cachedReader.byPath(virPath); + if (res) { + // Found resource in one of the cached readers. Assume it still belongs to the associated project + return this.#buildServerInterface.getReaderForProject(res.getProject().getName()); + } + } + + // Fallback 2: If the root project is of type application, and the request does not start with + // /resources/ or /test-resources/, test whether the resource can be found in the root project + if (this.#applicationProjectName && !virPath.startsWith("/resources/") && + !virPath.startsWith("/test-resources/")) { + const appReader = await this.#buildServerInterface.getReaderForProject(this.#applicationProjectName); + const res = await appReader.byPath(virPath); + if (res) { + return appReader; } } - return reader; + // Fallback to request a reader for all projects + return await this.#buildServerInterface.getReaderForProjects(this.#projectNames); } /** diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 5949a9a1d3d..b7bc16b6e63 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -31,13 +31,13 @@ class BuildServer extends EventEmitter { #projectBuilder; #watchHandler; #rootProjectName; - #buildQueue = new Map(); + #projectBuildStatus = new Map(); #pendingBuildRequest = new Set(); #activeBuild = null; + #processBuildRequestsTimeout; #allReader; #rootReader; #dependenciesReader; - #projectReaders = new Map(); /** * Creates a new BuildServer instance @@ -60,34 +60,42 @@ class BuildServer extends EventEmitter { this.#graph = graph; this.#rootProjectName = graph.getRoot().getName(); this.#projectBuilder = projectBuilder; - this.#allReader = new BuildReader("Build Server: All Projects Reader", - Array.from(this.#graph.getProjects()), - this.#getReaderForProject.bind(this), - this.#getReaderForProjects.bind(this)); + + const buildServerInterface = { + getReaderForProject: this.#getReaderForProject.bind(this), + getReaderForProjects: this.#getReaderForProjects.bind(this), + getCachedReadersForProjects: this.#getCachedReadersForProjects.bind(this), + }; + + this.#allReader = new BuildReader( + "Build Server: All Projects Reader", Array.from(this.#graph.getProjects()), buildServerInterface); + const rootProject = this.#graph.getRoot(); - this.#rootReader = new BuildReader("Build Server: Root Project Reader", - [rootProject], - this.#getReaderForProject.bind(this), - this.#getReaderForProjects.bind(this)); + this.#rootReader = new BuildReader("Build Server: Root Project Reader", [rootProject], buildServerInterface); + const dependencies = graph.getTransitiveDependencies(rootProject.getName()).map((dep) => graph.getProject(dep)); - this.#dependenciesReader = new BuildReader("Build Server: Dependencies Reader", - dependencies, - this.#getReaderForProject.bind(this), - this.#getReaderForProjects.bind(this)); + this.#dependenciesReader = new BuildReader( + "Build Server: Dependencies Reader", dependencies, buildServerInterface); + + // Initialize cache states + this.#projectBuildStatus.set(this.#rootProjectName, new ProjectBuildStatus()); + + for (const dep of dependencies) { + this.#projectBuildStatus.set(dep.getName(), new ProjectBuildStatus()); + } if (initialBuildRootProject) { - this.#pendingBuildRequest.add(this.#rootProjectName); + log.verbose("Enqueueing root project for initial build"); + this.#enqueueBuild(this.#rootProjectName); } if (initialBuildIncludedDependencies.length > 0) { // Enqueue initial build dependencies for (const projectName of initialBuildIncludedDependencies) { if (!initialBuildExcludedDependencies.includes(projectName)) { - this.#pendingBuildRequest.add(projectName); + log.verbose(`Enqueueing project '${projectName}' for initial build`); + this.#enqueueBuild(projectName); } } - this.#processBuildQueue().catch((err) => { - this.emit("error", err); - }); } const watchHandler = new WatchHandler(); @@ -102,26 +110,11 @@ class BuildServer extends EventEmitter { }); watchHandler.on("change", (eventType, filePath, project) => { log.verbose(`Source change detected: ${eventType} ${filePath} in project '${project.getName()}'`); - // TODO: Abort any active build + this.#projectResourceChangedLive(project, ["add", "unlink", "unlinkDir"].includes(eventType)); }); watchHandler.on("batchedChanges", (changes) => { log.verbose(`Received batched source changes for projects: ${[...changes.keys()].join(", ")}`); - - // Inform project builder - const affectedProjects = this.#projectBuilder.resourcesChanged(changes); - - for (const projectName of affectedProjects) { - log.verbose(`Invalidating built project '${projectName}' due to source changes`); - this.#projectReaders.delete(projectName); - // If project is currently in build queue, re-enqueue it for rebuild - if (this.#buildQueue.has(projectName)) { - log.verbose(`Re-enqueuing project '${projectName}' for rebuild`); - this.#pendingBuildRequest.add(projectName); - } - } - - const changedResourcePaths = [...changes.values()].flat(); - this.emit("sourcesChanged", changedResourcePaths); + this.#batchResourceChanges(changes); }); } @@ -185,10 +178,20 @@ class BuildServer extends EventEmitter { * @returns {Promise<@ui5/fs/AbstractReader>} Reader for the built project */ async #getReaderForProject(projectName) { - if (this.#projectReaders.has(projectName)) { - return this.#projectReaders.get(projectName); + if (!this.#projectBuildStatus.has(projectName)) { + throw new Error(`Project '${projectName}' not found in project graph`); + } + const projectBuildStatus = this.#projectBuildStatus.get(projectName); + + if (projectBuildStatus.isFresh()) { + return projectBuildStatus.getReader(); } - return this.#enqueueBuild(projectName); + const {promise, resolve, reject} = Promise.withResolvers(); + projectBuildStatus.addReaderRequest({resolve, reject}); + + log.verbose(`Reader for project '${projectName}' is not fresh. Enqueuing build request.`); + this.#enqueueBuild(projectName); + return promise; } /** @@ -201,43 +204,72 @@ class BuildServer extends EventEmitter { * @returns {Promise<@ui5/fs/ReaderCollection>} Combined reader for all requested projects */ async #getReaderForProjects(projectNames) { - // Enqueue all projects that aren't cached yet - const buildPromises = []; - for (const projectName of projectNames) { - if (!this.#projectReaders.has(projectName)) { - buildPromises.push(this.#enqueueBuild(projectName)); - } - } - // Wait for all builds to complete - if (buildPromises.length > 0) { - await Promise.all(buildPromises); + if (projectNames.length === 1) { + return await this.#getReaderForProject(projectNames[0]); } - return this.#getReaderForCachedProjects(projectNames); + const readers = await Promise.all(projectNames.map((projectName) => this.#getReaderForProject(projectName))); + return createReaderCollectionPrioritized({ + name: `Build Server: Reader for projects: ${projectNames.join(", ")}`, + readers + }); } - /** - * Creates a combined reader for already-built projects - * - * Retrieves readers from the cache for the specified projects and combines them - * into a prioritized reader collection. - * - * @param {string[]} projectNames Array of project names to combine - * @returns {@ui5/fs/ReaderCollection} Combined reader for cached projects - */ - #getReaderForCachedProjects(projectNames) { + #getCachedReadersForProjects(projectNames) { const readers = []; for (const projectName of projectNames) { - const reader = this.#projectReaders.get(projectName); + const projectBuildStatus = this.#projectBuildStatus.get(projectName); + const reader = projectBuildStatus.getReader(); if (reader) { readers.push(reader); } } + if (!readers.length) { + return; + } + return createReaderCollectionPrioritized({ - name: `Build Server: Reader for projects: ${projectNames.join(", ")}`, + name: `Build Server: Cached readers for projects: ${projectNames.join(", ")}`, readers }); } + /** + * Several projects might be affected by the source file change. + * However, at this time we can't tell for sure which ones: + * Only the project builder can determine the affected projects for a given (set of) source file changes. + * This check is only possible while no build is running, and is therefore only done in the batched change handler. + * + * Assuming that the change in source files might corrupt a currently running (or about to be started) build, + * we abort all active builds affecting the changed project or any of its dependents. + * + * @param {@ui5/project/specifications/Project} project Project where the resource change occurred + * @param {boolean} fileAddedOrRemoved Whether a file was added or removed + */ + #projectResourceChangedLive(project, fileAddedOrRemoved) { + for (const {project: affectedProject} of this.#graph.traverseDependents(project.getName(), true)) { + const projectBuildStatus = this.#projectBuildStatus.get(affectedProject.getName()); + projectBuildStatus.abortBuild("Source files changed"); + if (fileAddedOrRemoved) { + // Reset any cached readers in case files were added or removed + projectBuildStatus.resetReaderCache(); + } + } + } + + #batchResourceChanges(changes) { + // Inform project builder + const affectedProjects = this.#projectBuilder.resourcesChanged(changes); + + for (const projectName of affectedProjects) { + log.verbose(`Invalidating built project '${projectName}' due to source changes`); + const projectBuildStatus = this.#projectBuildStatus.get(projectName); + projectBuildStatus.invalidate(); + } + this.#triggerRequestQueue(); + + const changedResourcePaths = [...changes.values()].flat(); + this.emit("sourcesChanged", changedResourcePaths); + } /** * Enqueues a project for building and returns a promise that resolves with its reader * @@ -245,38 +277,34 @@ class BuildServer extends EventEmitter { * a new promise, adds the project to the pending build queue, and triggers queue processing. * * @param {string} projectName Name of the project to enqueue - * @returns {Promise<@ui5/fs/AbstractReader>} Promise that resolves with the project's reader */ #enqueueBuild(projectName) { - // If already queued, return existing promise - if (this.#buildQueue.has(projectName)) { - return this.#buildQueue.get(projectName).promise; + if (this.#pendingBuildRequest.has(projectName)) { + // Already queued + return; } log.verbose(`Enqueuing project '${projectName}' for build`); - // Create new promise for this project - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - // Store promise and resolvers in the queue - this.#buildQueue.set(projectName, {promise, resolve, reject}); - // Add to pending build requests this.#pendingBuildRequest.add(projectName); - // Trigger queue processing if no build is active - if (!this.#activeBuild) { - this.#processBuildQueue().catch((err) => { + this.#triggerRequestQueue(); + } + + #triggerRequestQueue() { + if (this.#activeBuild) { + return; + } + // If no build is active, trigger queue processing debounced + if (this.#processBuildRequestsTimeout) { + clearTimeout(this.#processBuildRequestsTimeout); + } + this.#processBuildRequestsTimeout = setTimeout(() => { + this.#processBuildRequests().catch((err) => { this.emit("error", err); }); - } - - return promise; + }, 10); } /** @@ -288,80 +316,132 @@ class BuildServer extends EventEmitter { * * @returns {Promise} Promise that resolves when queue processing is complete */ - async #processBuildQueue() { + async #processBuildRequests() { // Process queue while there are pending requests while (this.#pendingBuildRequest.size > 0) { // Collect all pending projects for this batch const projectsToBuild = Array.from(this.#pendingBuildRequest); let buildRootProject = false; - if (projectsToBuild.includes(this.#rootProjectName)) { + let dependenciesToBuild; + const rootProjectIdx = projectsToBuild.indexOf(this.#rootProjectName); + if (rootProjectIdx !== -1) { buildRootProject = true; + dependenciesToBuild = projectsToBuild.toSpliced(rootProjectIdx, 1); + } else { + dependenciesToBuild = projectsToBuild; } this.#pendingBuildRequest.clear(); log.verbose(`Building projects: ${projectsToBuild.join(", ")}`); + const signal = AbortSignal.any(projectsToBuild.map((projectName) => { + return this.#projectBuildStatus.get(projectName).getAbortSignal(); + })); // Set active build to prevent concurrent builds const buildPromise = this.#activeBuild = this.#projectBuilder.build({ includeRootProject: buildRootProject, - includedDependencies: projectsToBuild + includedDependencies: dependenciesToBuild, + signal, }, (projectName, project) => { - // Project has been built and result can be served - // TODO: Immediately resolve pending promises here instead of waiting for full build to finish + // Project has been built and result can be used + const projectBuildStatus = this.#projectBuildStatus.get(projectName); + projectBuildStatus.setReader(project.getReader({style: "runtime"})); }); try { const builtProjects = await buildPromise; - this.#projectBuildFinished(builtProjects); - - // Resolve promises for all successfully built projects - for (const projectName of builtProjects) { - const queueEntry = this.#buildQueue.get(projectName); - if (queueEntry) { - const reader = this.#projectReaders.get(projectName); - queueEntry.resolve(reader); - // Only remove from queue if not re-enqueued during build - if (!this.#pendingBuildRequest.has(projectName)) { - log.verbose(`Project '${projectName}' build finished. Removing from build queue.`); - this.#buildQueue.delete(projectName); + this.emit("buildFinished", builtProjects); + } catch (err) { + if (err.name === "AbortError") { + // Build was aborted - do not log as error + // Re-queue any outstanding projects + for (const projectName of projectsToBuild) { + const projectBuildStatus = this.#projectBuildStatus.get(projectName); + if (!projectBuildStatus.isFresh()) { + this.#pendingBuildRequest.add(projectName); } } - } - } catch (err) { - // Build failed - reject promises for projects that weren't built - for (const projectName of projectsToBuild) { - log.error(`Project '${projectName}' build failed: ${err.message}`); - const queueEntry = this.#buildQueue.get(projectName); - if (queueEntry && !this.#projectReaders.has(projectName)) { - queueEntry.reject(err); - this.#buildQueue.delete(projectName); - this.#pendingBuildRequest.delete(projectName); + } else { + log.error(`Build failed: ${err.message}`); + // Build failed - reject promises for projects that weren't built + for (const projectName of projectsToBuild) { + const projectBuildStatus = this.#projectBuildStatus.get(projectName); + projectBuildStatus.rejectReaderRequestes(err); } + // Re-throw to be handled by caller + throw err; } - // Re-throw to be handled by caller - throw err; } finally { // Clear active build this.#activeBuild = null; } + if (signal.aborted) { + log.verbose(`Build aborted for projects: ${projectsToBuild.join(", ")}`); + return; + } } } +} - /** - * Handles completion of a project build - * - * Caches readers for all built projects and emits the buildFinished event - * with the list of project names that were built. - * - * @param {string[]} projectNames Array of project names that were built - * @fires BuildServer#buildFinished - */ - #projectBuildFinished(projectNames) { - for (const projectName of projectNames) { - this.#projectReaders.set(projectName, - this.#graph.getProject(projectName).getReader({style: "runtime"})); +const PROJECT_STATES = Object.freeze({ + INITIAL: "initial", + INVALIDATED: "invalidated", + FRESH: "fresh", +}); + +class ProjectBuildStatus { + #state = PROJECT_STATES.INITIAL; + #readerQueue = []; + #reader; + #abortController = new AbortController(); + + invalidate() { + this.#state = PROJECT_STATES.INVALIDATED; + // Ensure any running build is aborted. Then reset the abort controller + this.#abortController.abort(); + this.#abortController = new AbortController(); + } + + abortBuild(reason) { + this.#abortController.abort(reason); + } + + getAbortSignal() { + return this.#abortController.signal; + } + + isFresh() { + return this.#state === PROJECT_STATES.FRESH; + } + + getReader() { + return this.#reader; + } + + setReader(reader) { + this.#reader = reader; + this.#state = PROJECT_STATES.FRESH; + // Resolve any queued getReader promises + for (const {resolve} of this.#readerQueue) { + resolve(reader); + } + this.#readerQueue = []; + } + + resetReaderCache() { + this.#reader = null; + } + + addReaderRequest(promiseResolvers) { + this.#readerQueue.push(promiseResolvers); + } + + rejectReaderRequestes(error) { + this.#state = PROJECT_STATES.INVALIDATED; + for (const {reject} of this.#readerQueue) { + reject(error); } - this.emit("buildFinished", projectNames); + this.#readerQueue = []; } } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 81c2872b83e..8331bd3bfd2 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -15,7 +15,7 @@ export const CACHE_STATES = Object.freeze({ INITIALIZING: "initializing", INITIALIZED: "initialized", EMPTY: "empty", - STALE: "stale", + REQUIRES_VALIDATION: "requires_validation", FRESH: "fresh", INVALIDATED: "invalidated", }); @@ -234,14 +234,17 @@ export default class ProjectBuildCache { * Array of resource paths written by the cached result stage, or undefined if no cache found */ async #findResultCache() { - if (this.#cacheState === CACHE_STATES.STALE && this.#currentResultSignature) { - log.verbose(`Project ${this.#project.getName()} cache state is stale but no changes have been detected. ` + + if (this.#cacheState === CACHE_STATES.REQUIRES_VALIDATION && this.#currentResultSignature) { + log.verbose( + `Project ${this.#project.getName()} cache requires validation but no changes have been detected. ` + `Continuing with current result stage: ${this.#currentResultSignature}`); this.#cacheState = CACHE_STATES.FRESH; return []; } - if (![CACHE_STATES.STALE, CACHE_STATES.INVALIDATED, CACHE_STATES.INITIALIZED].includes(this.#cacheState)) { + if (![ + CACHE_STATES.REQUIRES_VALIDATION, CACHE_STATES.INVALIDATED, CACHE_STATES.INITIALIZED + ].includes(this.#cacheState)) { log.verbose(`Project ${this.#project.getName()} cache state is ${this.#cacheState}, ` + `skipping result cache validation.`); return; @@ -675,7 +678,7 @@ export default class ProjectBuildCache { } /** - * Records changed source files of the project and marks cache as stale + * Records changed source files of the project and marks cache as requiring validation * * @public * @param {string[]} changedPaths Changed project source file paths @@ -687,13 +690,13 @@ export default class ProjectBuildCache { } } if (this.#cacheState !== CACHE_STATES.EMPTY) { - // If there is a cache, mark it as stale - this.#cacheState = CACHE_STATES.STALE; + // If there is a cache, mark it as requiring validation + this.#cacheState = CACHE_STATES.REQUIRES_VALIDATION; } } /** - * Records changed dependency resources and marks cache as stale + * Records changed dependency resources and marks cache as requiring validation * * @public * @param {string[]} changedPaths Changed dependency resource paths @@ -705,8 +708,8 @@ export default class ProjectBuildCache { } } if (this.#cacheState !== CACHE_STATES.EMPTY) { - // If there is a cache, mark it as stale - this.#cacheState = CACHE_STATES.STALE; + // If there is a cache, mark it as requiring validation + this.#cacheState = CACHE_STATES.REQUIRES_VALIDATION; } } diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 56bfb2c8c83..300553042f7 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -168,10 +168,10 @@ class BuildContext { const depChanges = dependencyChanges.get(dep.getName()); if (!depChanges) { dependencyChanges.set(dep.getName(), new Set(changedResourcePaths)); - continue; - } - for (const res of changedResourcePaths) { - depChanges.add(res); + } else { + for (const res of changedResourcePaths) { + depChanges.add(res); + } } } const projectBuildContext = this.getBuildContext(projectName); diff --git a/packages/project/test/lib/build/ProjectServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js similarity index 98% rename from packages/project/test/lib/build/ProjectServer.integration.js rename to packages/project/test/lib/build/BuildServer.integration.js index 0eed9db1d26..3bbac3fa8c9 100644 --- a/packages/project/test/lib/build/ProjectServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -58,6 +58,8 @@ test.serial("Serve application.a, request application resource", async (t) => { const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; await fs.appendFile(changedFilePath, `\ntest("line added");\n`); + await setTimeout(500); // Wait for the file watcher to detect and propagate the change + // #3 request with cache and changes const res = await fixtureTester.requestResource("/test.js", { projects: { @@ -74,7 +76,7 @@ test.serial("Serve application.a, request application resource", async (t) => { }); // Check whether the changed file is in the destPath - const servedFileContent = await res.toString(); + const servedFileContent = await res.getString(); t.true(servedFileContent.includes(`test("line added");`), "Resource contains changed file content"); }); From c8dc19667df912d7f479b3c2b377bf3d30d901a0 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 23 Jan 2026 18:40:17 +0100 Subject: [PATCH 114/223] refactor(project): Fix cache invalidation tracking --- .../project/lib/build/cache/ProjectBuildCache.js | 5 +++-- .../lib/build/cache/ResourceRequestManager.js | 13 +++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 8331bd3bfd2..2aa1dddafea 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -815,10 +815,10 @@ export default class ProjectBuildCache { // Source index is up-to-date, awaiting dependency indices validation // Status remains at initializing this.#cacheState = CACHE_STATES.INITIALIZING; + this.#cachedSourceSignature = resourceIndex.getSignature(); } this.#sourceIndex = resourceIndex; - this.#cachedSourceSignature = resourceIndex.getSignature(); - this.#changedProjectSourcePaths = changedPaths; + // Since all source files are part of the result, declare any detected changes as newly written resources this.#writtenResultResourcePaths = changedPaths; } else { // No index cache found, create new index @@ -853,6 +853,7 @@ export default class ProjectBuildCache { log.verbose(`Source resource index for project ${this.#project.getName()} updated: ` + `${removed.length} removed, ${added.length} added, ${updated.length} updated resources.`); const changedPaths = [...removed, ...added, ...updated]; + // Since all source files are part of the result, declare any detected changes as newly written resources for (const resourcePath of changedPaths) { if (!this.#writtenResultResourcePaths.includes(resourcePath)) { this.#writtenResultResourcePaths.push(resourcePath); diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 49bef2fd76f..ac848d6ce0b 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -244,11 +244,16 @@ class ResourceRequestManager { await resourceIndex.upsertResources(resourcesToUpdate); } } + let hasChanges; if (this.#useDifferentialUpdate) { - return await this.#flushTreeChangesWithDiffTracking(); + hasChanges = await this.#flushTreeChangesWithDiffTracking(); } else { - return await this.#flushTreeChangesWithoutDiffTracking(); + hasChanges = await this.#flushTreeChangesWithoutDiffTracking(); } + if (hasChanges) { + this.#hasNewOrModifiedCacheEntries = true; + } + return hasChanges; } /** @@ -461,6 +466,9 @@ class ResourceRequestManager { * @returns {string} Special signature "X" indicating no requests */ recordNoRequests() { + if (!this.#unusedAtLeastOnce) { + this.#hasNewOrModifiedCacheEntries = true; + } this.#unusedAtLeastOnce = true; return "X"; // Signature for when no requests were made } @@ -477,6 +485,7 @@ class ResourceRequestManager { * @returns {Promise} Object containing setId and signature of the resource index */ async #addRequestSet(requests, reader) { + this.#hasNewOrModifiedCacheEntries = true; // Try to find an existing request set that we can reuse let setId = this.#requestGraph.findExactMatch(requests); let resourceIndex; From 1f2bbb9e5ed95b5abe4c6be6487d73e88361e1bf Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 10:47:49 +0100 Subject: [PATCH 115/223] refactor(project): Fix stage restore Stages where not always correctly restored from cache due to a separate initialization of all current stages (initStages) --- packages/project/lib/build/cache/ProjectBuildCache.js | 9 ++++++--- packages/project/lib/specifications/ComponentProject.js | 8 ++++---- packages/project/lib/specifications/Project.js | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 2aa1dddafea..7c6c0c1dc84 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -294,7 +294,10 @@ export default class ProjectBuildCache { */ async #importStages(stageSignatures) { const stageNames = Object.keys(stageSignatures); - this.#project.initStages(stageNames); + if (this.#project.getStage()?.getId() === "initial") { + // Only initialize stages once + this.#project.initStages(stageNames); + } const importedStages = await Promise.all(stageNames.map(async (stageName) => { const stageSignature = stageSignatures[stageName]; const stageCache = await this.#findStageCache(stageName, [stageSignature]); @@ -416,10 +419,10 @@ export default class ProjectBuildCache { const stageCache = await this.#findStageCache(stageName, stageSignatures); const oldStageSig = this.#currentStageSignatures.get(stageName)?.join("-"); if (stageCache) { + this.#project.setStage(stageName, stageCache.stage); + // Check whether the stage actually changed if (stageCache.signature !== oldStageSig) { - this.#project.setStage(stageName, stageCache.stage); - // Store new stage signature for later use in result stage signature calculation this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); diff --git a/packages/project/lib/specifications/ComponentProject.js b/packages/project/lib/specifications/ComponentProject.js index d595d0085ff..654c49c1b08 100644 --- a/packages/project/lib/specifications/ComponentProject.js +++ b/packages/project/lib/specifications/ComponentProject.js @@ -150,22 +150,22 @@ class ComponentProject extends Project { throw new Error(`_getTestReader must be implemented by subclass ${this.constructor.name}`); } - _createWriter() { + _createWriter(stageId) { // writer is always of style "buildtime" const namespaceWriter = resourceFactory.createAdapter({ - name: `Namespace writer for project ${this.getName()}`, + name: `Namespace writer for project ${this.getName()}, stage ${stageId}`, virBasePath: "/", project: this }); const generalWriter = resourceFactory.createAdapter({ - name: `General writer for project ${this.getName()}`, + name: `General writer for project ${this.getName()}, stage ${stageId}`, virBasePath: "/", project: this }); const collection = resourceFactory.createWriterCollection({ - name: `Writers for project ${this.getName()}`, + name: `Writers for project ${this.getName()}, stage ${stageId}`, writerMapping: { [`/resources/${this._namespace}/`]: namespaceWriter, [`/test-resources/${this._namespace}/`]: namespaceWriter, diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index e366aee917a..ffcabd2ead0 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -384,7 +384,7 @@ class Project extends Specification { _initStageMetadata() { this.#stages = []; // Initialize with an empty stage for use without stages (i.e. without build cache) - this.#currentStage = new Stage(INITIAL_STAGE_ID, this._createWriter()); + this.#currentStage = new Stage(INITIAL_STAGE_ID, this._createWriter(INITIAL_STAGE_ID)); this.#currentStageId = INITIAL_STAGE_ID; this.#currentStageReadIndex = -1; this.#currentStageReaders = new Map(); @@ -407,7 +407,7 @@ class Project extends Specification { this._initStageMetadata(); for (let i = 0; i < stageIds.length; i++) { const stageId = stageIds[i]; - const newStage = new Stage(stageId, this._createWriter()); + const newStage = new Stage(stageId, this._createWriter(stageId)); this.#stages.push(newStage); } } From c6e34eab359367ab37342bca6fce2df2a2355427 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 11:21:29 +0100 Subject: [PATCH 116/223] refactor(project): Add cache support for custom tasks --- packages/project/lib/build/TaskRunner.js | 46 ++++++++++++++----- .../lib/specifications/extensions/Task.js | 4 +- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 95a09c02c2f..0b1815552e7 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -191,7 +191,7 @@ class TaskRunner { task = async (log) => { options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); - // TODO: Apply cache and stage handling for custom tasks as well + const cacheInfo = await this._buildCache.prepareTaskExecutionAndValidateCache(taskName); if (cacheInfo === true) { this._log.skipTask(taskName); @@ -305,9 +305,9 @@ class TaskRunner { // Tasks can provide an optional callback to tell build process which dependencies they require const requiredDependenciesCallback = await task.getRequiredDependenciesCallback(); - const getBuildSignatureCallback = await task.getBuildSignatureCallback(); - const getExpectedOutputCallback = await task.getExpectedOutputCallback(); - const differentialUpdateCallback = await task.getDifferentialUpdateCallback(); + // const buildSignatureCallback = await task.getBuildSignatureCallback(); + // const expectedOutputCallback = await task.getExpectedOutputCallback(); + const supportsDifferentialUpdatesCallback = await task.getSupportsDifferentialUpdatesCallback(); const specVersion = task.getSpecVersion(); let requiredDependencies; @@ -370,6 +370,10 @@ class TaskRunner { } }); } + let supportsDifferentialUpdates = false; + if (specVersion.gte("5.0") && supportsDifferentialUpdatesCallback && supportsDifferentialUpdatesCallback()) { + supportsDifferentialUpdates = true; + } this._tasks[newTaskName] = { task: this._createCustomTaskWrapper({ @@ -379,9 +383,7 @@ class TaskRunner { taskName: newTaskName, taskConfiguration: taskDef.configuration, provideDependenciesReader, - getBuildSignatureCallback, - getExpectedOutputCallback, - differentialUpdateCallback, + supportsDifferentialUpdates, getDependenciesReaderCb: () => { // Create the dependencies reader on-demand return this.getDependenciesReader(requiredDependencies); @@ -417,9 +419,17 @@ class TaskRunner { } _createCustomTaskWrapper({ - project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, task, taskName, taskConfiguration + project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, supportsDifferentialUpdates, + task, taskName, taskConfiguration }) { return async () => { + const cacheInfo = await this._buildCache.prepareTaskExecutionAndValidateCache(taskName); + if (cacheInfo === true) { + this._log.skipTask(taskName); + return; + } + const usingCache = !!(supportsDifferentialUpdates && cacheInfo); + /* Custom Task Interface Parameters: {Object} parameters Parameters @@ -442,14 +452,21 @@ class TaskRunner { Returns: {Promise} Promise resolving with undefined once data has been written */ + const workspace = createMonitor(this._project.getWorkspace()); const params = { - workspace: project.getWorkspace(), + workspace, options: { projectName: project.getName(), projectNamespace: project.getNamespace(), configuration: taskConfiguration, } }; + if (usingCache) { + params.changedProjectResourcePaths = cacheInfo.changedProjectResourcePaths; + if (provideDependenciesReader) { + params.changedDependencyResourcePaths = cacheInfo.changedDependencyResourcePaths; + } + } const specVersion = task.getSpecVersion(); const taskUtilInterface = taskUtil.getInterface(specVersion); // Interface is undefined if specVersion does not support taskUtil @@ -463,12 +480,19 @@ class TaskRunner { params.log = getLogger(`builder:custom-task:${taskName}`); } + let dependencies; if (provideDependenciesReader) { - params.dependencies = await getDependenciesReaderCb(); + dependencies = createMonitor(await getDependenciesReaderCb()); + params.dependencies = dependencies; } - this._log.startTask(taskName, false); + this._log.startTask(taskName, usingCache); await taskFunction(params); this._log.endTask(taskName); + await this._buildCache.recordTaskResult(taskName, + workspace.getResourceRequests(), + dependencies?.getResourceRequests(), + usingCache ? cacheInfo : undefined, + supportsDifferentialUpdates); }; } diff --git a/packages/project/lib/specifications/extensions/Task.js b/packages/project/lib/specifications/extensions/Task.js index e8cefcc7a94..7878737488f 100644 --- a/packages/project/lib/specifications/extensions/Task.js +++ b/packages/project/lib/specifications/extensions/Task.js @@ -41,8 +41,8 @@ class Task extends Extension { /** * @public */ - async getDifferentialUpdateCallback() { - return (await this._getImplementation()).differentialUpdate; + async getSupportsDifferentialUpdatesCallback() { + return (await this._getImplementation()).supportsDifferentialUpdates; } /** From 5b426c2e7a99b43d4341b7366bacb4047efb6b68 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Mon, 26 Jan 2026 11:48:13 +0100 Subject: [PATCH 117/223] test(project): Add file deletion case for theme.library.e --- .../lib/build/ProjectBuilder.integration.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 338393ddbd3..7933eb9ea0e 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -313,9 +313,29 @@ test.serial("Build theme.library.e project multiple times", async (t) => { assertions: { projects: {"theme.library.e": { skippedTasks: ["buildThemes"] + // NOTE: buildThemes currently gets NOT skipped -> TODO: fix }}, } }); + + // Check if library.css does NOT contain the imported rule anymore + t.false( + (await fs.readFile( + `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} + )).includes(`.someOtherNewClass`), + "Build dest should NOT contain the rule in library.css anymore" + ); + + // Delete the imported less file + await fs.rm(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`); + + // #8 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {}, // -> everything should be skipped + } + }); }); function getFixturePath(fixtureName) { From 44c252a80343fd7443c4239138b35754ce634971 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 12:53:20 +0100 Subject: [PATCH 118/223] fix(builder): Filter out non-JS resources in minify task --- packages/builder/lib/tasks/minify.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index 5fdccc8124f..439f53fc9a0 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -33,7 +33,13 @@ export default async function({ }) { let resources; if (changedProjectResourcePaths) { - resources = await Promise.all(changedProjectResourcePaths.map((resource) => workspace.byPath(resource))); + resources = await Promise.all( + changedProjectResourcePaths + // Filtering out non-JS resources such as .map files + // FIXME: The changed resources should rather be matched against the provided pattern + .filter((resourcePath) => resourcePath.endsWith(".js")) + .map((resource) => workspace.byPath(resource)) + ); } else { resources = await workspace.byGlob(pattern); } From d9f4f895be03e4ad5d0a53dc4b3fc680b7d08b85 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 13:31:34 +0100 Subject: [PATCH 119/223] test(project): Add test case for minify task fix Covers the case that was fixed with da88f526832597a30f3efdad7b521bce2b9c8cd1 --- .../webapp/thirdparty/scriptWithSourceMap.js | 2 + .../thirdparty/scriptWithSourceMap.js.map | 1 + .../lib/build/ProjectBuilder.integration.js | 38 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js create mode 100644 packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js.map diff --git a/packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js b/packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js new file mode 100644 index 00000000000..5c633c08703 --- /dev/null +++ b/packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js @@ -0,0 +1,2 @@ +console.log("This is a script with a source map."); +//# sourceMappingURL=scriptWithSourceMap.js.map diff --git a/packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js.map b/packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js.map new file mode 100644 index 00000000000..5e28dc9849d --- /dev/null +++ b/packages/project/test/fixtures/application.a/webapp/thirdparty/scriptWithSourceMap.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scriptWithSourceMap.js","names":["console","log"],"sources":["scriptWithSourceMap.ts"],"sourcesContent":["console.log(\"This is a script with a source map.\");\n"],"mappings":"AAAAA,QAAQC,IAAI","ignoreList":[]} diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 7933eb9ea0e..cb79e2755f1 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -139,6 +139,44 @@ test.serial("Build application.a project multiple times", async (t) => { projects: {} } }); + + // Change a source file with existing source map in application.a + const fileWithSourceMapPath = + `${fixtureTester.fixturePath}/webapp/thirdparty/scriptWithSourceMap.js`; + const fileWithSourceMapContent = await fs.readFile(fileWithSourceMapPath, {encoding: "utf8"}); + await fs.writeFile( + fileWithSourceMapPath, + fileWithSourceMapContent.replace( + `This is a script with a source map.`, + `This is a CHANGED script with a source map.` + ) + ); + const sourceMapPath = `${fixtureTester.fixturePath}/webapp/thirdparty/scriptWithSourceMap.js.map`; + const sourceMapContent = await fs.readFile(sourceMapPath, {encoding: "utf8"}); + await fs.writeFile( + sourceMapPath, + sourceMapContent.replace( + `This is a script with a source map.`, + `This is a CHANGED script with a source map.` + ) + ); + + // #9 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright" + ] + } + } + } + }); }); test.serial("Build library.d project multiple times", async (t) => { From e15313f27e7ca0a6eff2597eff2c54fb4e26c669 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 12:37:25 +0100 Subject: [PATCH 120/223] test(project): Add ResourceRequestManager tests --- .../lib/build/cache/ResourceRequestManager.js | 9 +- .../lib/build/cache/ResourceRequestManager.js | 695 ++++++++++++++++++ 2 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 packages/project/test/lib/build/cache/ResourceRequestManager.js diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index ac848d6ce0b..21402f9fdf7 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -270,11 +270,16 @@ class ResourceRequestManager { const matchedResources = []; for (const {type, value} of resourceRequests) { if (type === "path") { - if (resourcePaths.includes(value)) { + if (resourcePaths.includes(value) && !matchedResources.includes(value)) { matchedResources.push(value); } } else { - matchedResources.push(...micromatch(resourcePaths, value)); + const globMatches = micromatch(resourcePaths, value); + for (const match of globMatches) { + if (!matchedResources.includes(match)) { + matchedResources.push(match); + } + } } } return matchedResources; diff --git a/packages/project/test/lib/build/cache/ResourceRequestManager.js b/packages/project/test/lib/build/cache/ResourceRequestManager.js new file mode 100644 index 00000000000..0d142c491dd --- /dev/null +++ b/packages/project/test/lib/build/cache/ResourceRequestManager.js @@ -0,0 +1,695 @@ +import test from "ava"; +import sinon from "sinon"; +import ResourceRequestManager from "../../../../lib/build/cache/ResourceRequestManager.js"; +import ResourceRequestGraph from "../../../../lib/build/cache/ResourceRequestGraph.js"; + +// Helper to create mock Resource instances +function createMockResource(path, integrity = "test-hash", lastModified = 1000, size = 100, inode = 1) { + return { + getOriginalPath: () => path, + getPath: () => path, + getIntegrity: async () => integrity, + getLastModified: () => lastModified, + getSize: async () => size, + getInode: () => inode, + getBuffer: async () => Buffer.from("test content"), + getStream: () => null + }; +} + +// Helper to create mock Reader (project or dependency) +function createMockReader(resources = new Map()) { + return { + byPath: sinon.stub().callsFake(async (path) => { + return resources.get(path) || null; + }), + byGlob: sinon.stub().callsFake(async (patterns) => { + const patternArray = Array.isArray(patterns) ? patterns : [patterns]; + const results = []; + for (const [path, resource] of resources.entries()) { + for (const pattern of patternArray) { + // Simple pattern matching + if (pattern === "/**/*" || pattern === "**/*") { + results.push(resource); + break; + } + // Convert glob pattern to regex + const regex = new RegExp(pattern.replace(/\*/g, ".*").replace(/\?/g, ".")); + if (regex.test(path)) { + results.push(resource); + break; + } + } + } + return results; + }) + }; +} + +test.afterEach.always(() => { + sinon.restore(); +}); + +// ===== CONSTRUCTOR TESTS ===== + +test("ResourceRequestManager: Create new instance", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + + t.truthy(manager, "Manager instance created"); + t.true(manager.hasNewOrModifiedCacheEntries(), "New manager has modified entries"); +}); + +test("ResourceRequestManager: Create with request graph from cache", (t) => { + const graph = new ResourceRequestGraph(); + const manager = new ResourceRequestManager("test.project", "myTask", false, graph); + + t.truthy(manager, "Manager instance created with graph"); + t.false(manager.hasNewOrModifiedCacheEntries(), "Manager restored from cache has no new entries initially"); +}); + +test("ResourceRequestManager: Create with differential update enabled", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", true); + + t.truthy(manager, "Manager instance created with differential updates"); +}); + +test("ResourceRequestManager: Create with unusedAtLeastOnce flag", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false, null, true); + + t.truthy(manager, "Manager instance created"); + const signatures = manager.getIndexSignatures(); + t.true(signatures.includes("X"), "Signatures include 'X' for unused state"); +}); + +// ===== fromCache FACTORY METHOD TESTS ===== + +test("ResourceRequestManager: fromCache with basic data", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + // Create a manager and add some requests + const manager1 = new ResourceRequestManager("test.project", "myTask", false); + await manager1.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + // Serialize and restore + const cacheData = manager1.toCacheObject(); + t.truthy(cacheData, "Cache data created"); + + const manager2 = ResourceRequestManager.fromCache("test.project", "myTask", false, cacheData); + + t.truthy(manager2, "Manager restored from cache"); + t.false(manager2.hasNewOrModifiedCacheEntries(), "Restored manager has no new entries"); +}); + +test("ResourceRequestManager: fromCache with unusedAtLeastOnce", (t) => { + const cacheData = { + requestSetGraph: { + nodes: [], + nextId: 1 + }, + rootIndices: [], + unusedAtLeastOnce: true + }; + + const manager = ResourceRequestManager.fromCache("test.project", "myTask", false, cacheData); + + t.truthy(manager, "Manager restored"); + const signatures = manager.getIndexSignatures(); + t.true(signatures.includes("X"), "Includes 'X' signature for unused state"); +}); + +// ===== addRequests TESTS ===== + +test("ResourceRequestManager: Add path requests", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const result = await manager.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + t.truthy(result, "Result returned"); + t.truthy(result.setId, "Result has setId"); + t.truthy(result.signature, "Result has signature"); + t.is(typeof result.signature, "string", "Signature is a string"); +}); + +test("ResourceRequestManager: Add pattern requests", async (t) => { + const resources = new Map([ + ["/src/a.js", createMockResource("/src/a.js", "hash-a")], + ["/src/b.js", createMockResource("/src/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const result = await manager.addRequests({ + paths: [], + patterns: [["/src/*.js"]] + }, reader); + + t.truthy(result, "Result returned"); + t.truthy(result.signature, "Result has signature"); +}); + +test("ResourceRequestManager: Add multiple request sets", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")], + ["/c.js", createMockResource("/c.js", "hash-c")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + + // First request set + const result1 = await manager.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + // Second request set (superset) + const result2 = await manager.addRequests({ + paths: ["/a.js", "/b.js", "/c.js"], + patterns: [] + }, reader); + + t.not(result1.signature, result2.signature, "Different signatures for different request sets"); + t.true(manager.hasNewOrModifiedCacheEntries(), "Has new entries"); +}); + +test("ResourceRequestManager: Reuse existing request set", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + + // Add first request set + const result1 = await manager.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + // Add identical request set + const result2 = await manager.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + t.is(result1.setId, result2.setId, "Same setId for identical requests"); + t.is(result1.signature, result2.signature, "Same signature for identical requests"); +}); + +// ===== recordNoRequests TESTS ===== + +test("ResourceRequestManager: Record no requests", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const signature = manager.recordNoRequests(); + + t.is(signature, "X", "Returns 'X' signature"); + t.true(manager.hasNewOrModifiedCacheEntries(), "Has new entries"); +}); + +test("ResourceRequestManager: Record no requests multiple times", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const sig1 = manager.recordNoRequests(); + const sig2 = manager.recordNoRequests(); + + t.is(sig1, "X", "First call returns 'X'"); + t.is(sig2, "X", "Second call returns 'X'"); +}); + +// ===== getIndexSignatures TESTS ===== + +test("ResourceRequestManager: Get signatures from empty manager", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const signatures = manager.getIndexSignatures(); + + t.is(signatures.length, 0, "No signatures for empty manager"); +}); + +test("ResourceRequestManager: Get signatures after adding requests", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + const signatures = manager.getIndexSignatures(); + + t.is(signatures.length, 1, "One signature"); + t.is(typeof signatures[0], "string", "Signature is a string"); +}); + +test("ResourceRequestManager: Get signatures includes 'X' when unused", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + manager.recordNoRequests(); + + const signatures = manager.getIndexSignatures(); + + t.true(signatures.includes("X"), "Includes 'X' signature"); +}); + +// ===== hasNewOrModifiedCacheEntries TESTS ===== + +test("ResourceRequestManager: New manager has modified entries", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + + t.true(manager.hasNewOrModifiedCacheEntries(), "New manager has modified entries"); +}); + +test("ResourceRequestManager: Restored manager has no modified entries initially", (t) => { + const graph = new ResourceRequestGraph(); + const manager = new ResourceRequestManager("test.project", "myTask", false, graph); + + t.false(manager.hasNewOrModifiedCacheEntries(), "Restored manager has no modified entries"); +}); + +test("ResourceRequestManager: Adding requests marks as modified", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + const graph = new ResourceRequestGraph(); + const manager = new ResourceRequestManager("test.project", "myTask", false, graph); + + t.false(manager.hasNewOrModifiedCacheEntries(), "Initially no modified entries"); + + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + t.true(manager.hasNewOrModifiedCacheEntries(), "Has modified entries after adding requests"); +}); + +// ===== toCacheObject TESTS ===== + +test("ResourceRequestManager: Serialize to cache object", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + const cacheObj = manager.toCacheObject(); + + t.truthy(cacheObj, "Cache object created"); + t.truthy(cacheObj.requestSetGraph, "Has requestSetGraph"); + t.truthy(cacheObj.rootIndices, "Has rootIndices"); + t.true(Array.isArray(cacheObj.rootIndices), "rootIndices is an array"); +}); + +test("ResourceRequestManager: Serialize returns undefined when no changes", (t) => { + const graph = new ResourceRequestGraph(); + const manager = new ResourceRequestManager("test.project", "myTask", false, graph); + + const cacheObj = manager.toCacheObject(); + + t.is(cacheObj, undefined, "Returns undefined when no changes"); +}); + +test("ResourceRequestManager: Serialize includes unusedAtLeastOnce", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", false); + manager.recordNoRequests(); + + const cacheObj = manager.toCacheObject(); + + t.truthy(cacheObj, "Cache object created"); + t.true(cacheObj.unusedAtLeastOnce, "Includes unusedAtLeastOnce flag"); +}); + +test("ResourceRequestManager: Serialize with differential updates", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", true); + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + const cacheObj = manager.toCacheObject(); + + t.truthy(cacheObj, "Cache object created"); + t.truthy(cacheObj.deltaIndices, "Has deltaIndices"); + t.true(Array.isArray(cacheObj.deltaIndices), "deltaIndices is an array"); +}); + +// ===== getDeltas TESTS ===== + +test("ResourceRequestManager: Get deltas returns empty map initially", (t) => { + const manager = new ResourceRequestManager("test.project", "myTask", true); + + const deltas = manager.getDeltas(); + + t.true(deltas instanceof Map, "Returns a Map"); + t.is(deltas.size, 0, "Empty initially"); +}); + +test("ResourceRequestManager: Get deltas after updates", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", true); + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + // Update resource + resources.set("/a.js", createMockResource("/a.js", "hash-a-new")); + + // Note: In a real scenario, updateIndices would be called here + // For this test, we're just checking the method exists and returns a Map + const deltas = manager.getDeltas(); + + t.true(deltas instanceof Map, "Returns a Map"); +}); + +// ===== updateIndices TESTS ===== + +test("ResourceRequestManager: updateIndices with no requests", async (t) => { + const reader = createMockReader(new Map()); + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const hasChanges = await manager.updateIndices(reader, []); + + t.false(hasChanges, "No changes when no requests recorded"); +}); + +test("ResourceRequestManager: updateIndices with no changed paths", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + const hasChanges = await manager.updateIndices(reader, []); + + t.false(hasChanges, "No changes when paths don't match"); +}); + +test("ResourceRequestManager: updateIndices with matching changed path", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + // Update the resource + resources.set("/a.js", createMockResource("/a.js", "hash-a-new")); + + const hasChanges = await manager.updateIndices(reader, ["/a.js"]); + + t.true(hasChanges, "Detects changes for matching path"); +}); + +test("ResourceRequestManager: updateIndices with pattern matches", async (t) => { + const resources = new Map([ + ["/src/a.js", createMockResource("/src/a.js", "hash-a")], + ["/src/b.js", createMockResource("/src/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({ + paths: [], + patterns: [["/src/*.js"]] + }, reader); + + // Update one resource + resources.set("/src/a.js", createMockResource("/src/a.js", "hash-a-new")); + + const hasChanges = await manager.updateIndices(reader, ["/src/a.js"]); + + t.true(hasChanges, "Detects changes for pattern-matched resources"); +}); + +test("ResourceRequestManager: updateIndices with removed resource", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({paths: ["/a.js", "/b.js"], patterns: []}, reader); + + // Remove a resource + resources.delete("/b.js"); + + const hasChanges = await manager.updateIndices(reader, ["/b.js"]); + + t.true(hasChanges, "Detects removal of resource"); +}); + +// ===== refreshIndices TESTS ===== + +test("ResourceRequestManager: refreshIndices with no requests", async (t) => { + const reader = createMockReader(new Map()); + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const hasChanges = await manager.refreshIndices(reader); + + t.false(hasChanges, "No changes when no requests recorded"); +}); + +test("ResourceRequestManager: refreshIndices after adding requests", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + await manager.addRequests({paths: ["/a.js", "/b.js"], patterns: []}, reader); + + // Update resources + resources.set("/a.js", createMockResource("/a.js", "hash-a-new")); + + const result = await manager.refreshIndices(reader); + + // refreshIndices doesn't return a value (undefined) when changes are made + // It only returns false when there are no requests + t.is(result, undefined, "refreshIndices returns undefined when it processes changes"); + t.pass("refreshIndices completed"); +}); + +// ===== INTEGRATION TESTS ===== + +test("ResourceRequestManager: Complete workflow", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + // 1. Create manager + const manager = new ResourceRequestManager("test.project", "myTask", false); + + // 2. Add requests + const result = await manager.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + t.truthy(result.signature, "Got signature from addRequests"); + + // 3. Get signatures + const signatures = manager.getIndexSignatures(); + t.is(signatures.length, 1, "One signature recorded"); + t.is(signatures[0], result.signature, "Signature matches"); + + // 4. Serialize + const cacheObj = manager.toCacheObject(); + t.truthy(cacheObj, "Can serialize to cache object"); + + // 5. Restore from cache + const manager2 = ResourceRequestManager.fromCache("test.project", "myTask", false, cacheObj); + t.truthy(manager2, "Can restore from cache"); + + const signatures2 = manager2.getIndexSignatures(); + t.deepEqual(signatures2, signatures, "Restored manager has same signatures"); +}); + +test("ResourceRequestManager: Differential update workflow", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")] + ]); + const reader = createMockReader(resources); + + // Create with differential updates enabled + const manager = new ResourceRequestManager("test.project", "myTask", true); + + // Add requests + await manager.addRequests({paths: ["/a.js"], patterns: []}, reader); + + // Update resource + resources.set("/a.js", createMockResource("/a.js", "hash-a-new")); + + // Update indices + const hasChanges = await manager.updateIndices(reader, ["/a.js"]); + t.true(hasChanges, "Detected changes"); + + // Get deltas + const deltas = manager.getDeltas(); + t.true(deltas instanceof Map, "Has deltas"); + + // Serialize + const cacheObj = manager.toCacheObject(); + t.truthy(cacheObj, "Can serialize"); + t.truthy(cacheObj.deltaIndices, "Has delta indices"); +}); + +test("ResourceRequestManager: Mixed path and pattern requests", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/src/b.js", createMockResource("/src/b.js", "hash-b")], + ["/src/c.js", createMockResource("/src/c.js", "hash-c")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const result = await manager.addRequests({ + paths: ["/a.js"], + patterns: [["/src/*.js"]] + }, reader); + + t.truthy(result.signature, "Got signature for mixed requests"); + + const signatures = manager.getIndexSignatures(); + t.is(signatures.length, 1, "One signature"); +}); + +test("ResourceRequestManager: Hierarchical request sets", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")], + ["/c.js", createMockResource("/c.js", "hash-c")] + ]); + const reader = createMockReader(resources); + + const manager = new ResourceRequestManager("test.project", "myTask", false); + + // First request set + const result1 = await manager.addRequests({ + paths: ["/a.js"], + patterns: [] + }, reader); + + // Second request set (superset) + const result2 = await manager.addRequests({ + paths: ["/a.js", "/b.js"], + patterns: [] + }, reader); + + // Third request set (superset) + const result3 = await manager.addRequests({ + paths: ["/a.js", "/b.js", "/c.js"], + patterns: [] + }, reader); + + const signatures = manager.getIndexSignatures(); + t.is(signatures.length, 3, "Three different signatures"); + t.not(result1.signature, result2.signature, "Different signatures"); + t.not(result2.signature, result3.signature, "Different signatures"); +}); + +test("ResourceRequestManager: Empty request sets", async (t) => { + const reader = createMockReader(new Map()); + const manager = new ResourceRequestManager("test.project", "myTask", false); + + const result = await manager.addRequests({ + paths: [], + patterns: [] + }, reader); + + t.truthy(result, "Can add empty request set"); + t.truthy(result.signature, "Has signature even when empty"); +}); + +test("ResourceRequestManager: Serialization round-trip with multiple request sets", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + // Create manager and add multiple request sets (keep it simpler - two levels) + const manager1 = new ResourceRequestManager("test.project", "myTask", false); + await manager1.addRequests({paths: ["/a.js"], patterns: []}, reader); + await manager1.addRequests({paths: ["/a.js", "/b.js"], patterns: []}, reader); + + const signatures1 = manager1.getIndexSignatures(); + + // Serialize and restore + const cacheObj = manager1.toCacheObject(); + const manager2 = ResourceRequestManager.fromCache("test.project", "myTask", false, cacheObj); + + const signatures2 = manager2.getIndexSignatures(); + + t.deepEqual(signatures2, signatures1, "Signatures preserved through serialization"); + t.false(manager2.hasNewOrModifiedCacheEntries(), "Restored manager has no new entries"); +}); + +test("ResourceRequestManager: Serialization round-trip with multiple request sets and following update", async (t) => { + const resources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], + ["/b.js", createMockResource("/b.js", "hash-b")] + ]); + const reader = createMockReader(resources); + + // Create manager and add multiple request sets (keep it simpler - two levels) + const manager1 = new ResourceRequestManager("test.project", "myTask", false); + await manager1.addRequests({paths: ["/a.js"], patterns: []}, reader); + await manager1.addRequests({paths: ["/a.js", "/b.js"], patterns: []}, reader); + + const signatures1 = manager1.getIndexSignatures(); + + // Serialize and restore + const cacheObj = manager1.toCacheObject(); + const manager2 = ResourceRequestManager.fromCache("test.project", "myTask", false, cacheObj); + + + const changedResources = new Map([ + ["/a.js", createMockResource("/a.js", "hash-a")], // Identical to first + ["/b.js", createMockResource("/b.js", "hash-y")] + ]); + const changedReader = createMockReader(changedResources); + + const hasChanges = await manager2.updateIndices(changedReader, ["/a.js", "/b.js"]); + + t.true(hasChanges, "Detected changes after update"); + + const signatures2 = manager2.getIndexSignatures(); + + t.is(signatures2[0], signatures1[0], "Unchanged signature of first request set"); + t.not(signatures2[1], signatures1[1], "Changed signature of second request set"); + t.true(manager2.hasNewOrModifiedCacheEntries(), "Restored manager has new entries"); +}); + From c46a285c92a61aa67e7e3a33866220bb95e0b111 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 14:07:24 +0100 Subject: [PATCH 121/223] refactor(project): Fix derived trees unexpected upsert in parents --- .../lib/build/cache/index/SharedHashTree.js | 7 +- .../lib/build/cache/index/TreeRegistry.js | 121 +++++++++--- .../lib/build/ProjectBuilder.integration.js | 1 - .../lib/build/cache/index/SharedHashTree.js | 172 +++++++++++++++++- .../lib/build/cache/index/TreeRegistry.js | 22 ++- 5 files changed, 293 insertions(+), 30 deletions(-) diff --git a/packages/project/lib/build/cache/index/SharedHashTree.js b/packages/project/lib/build/cache/index/SharedHashTree.js index abea227cd5b..e4c18514089 100644 --- a/packages/project/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/lib/build/cache/index/SharedHashTree.js @@ -56,7 +56,7 @@ export default class SharedHashTree extends HashTree { } for (const resource of resources) { - this.registry.scheduleUpsert(resource, newIndexTimestamp); + this.registry.scheduleUpsert(resource, newIndexTimestamp, this); } } @@ -99,6 +99,11 @@ export default class SharedHashTree extends HashTree { _root: derivedRoot }); + // Register the derived tree with parent tree reference + if (this.registry) { + this.registry.register(derived, this); + } + return derived; } diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index a127e36db90..2781d731c86 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -17,14 +17,18 @@ import {matchResourceMetadataStrict} from "../utils.js"; * This approach ensures consistency when multiple trees represent filtered views of the same underlying data. * * @property {Set} trees - All registered HashTree/SharedHashTree instances - * @property {Map} pendingUpserts - Resource path to resource mappings for scheduled upserts + * @property {Map} + * pendingUpserts - Resource path to resource and source tree mappings for scheduled upserts * @property {Set} pendingRemovals - Resource paths scheduled for removal + * @property {Map>} derivedTrees + * Maps parent trees to their directly derived children */ export default class TreeRegistry { trees = new Set(); pendingUpserts = new Map(); pendingRemovals = new Set(); pendingTimestampUpdate; + derivedTrees = new Map(); // parent -> Set of derived trees /** * Register a HashTree or SharedHashTree instance with this registry for coordinated updates. @@ -33,9 +37,17 @@ export default class TreeRegistry { * Multiple trees can share the same underlying nodes through structural sharing. * * @param {import('./SharedHashTree.js').default} tree - HashTree or SharedHashTree instance to register + * @param {import('./SharedHashTree.js').default} [parentTree] - Parent tree if this is a derived tree */ - register(tree) { + register(tree, parentTree = null) { this.trees.add(tree); + + if (parentTree) { + if (!this.derivedTrees.has(parentTree)) { + this.derivedTrees.set(parentTree, new Set()); + } + this.derivedTrees.get(parentTree).add(tree); + } } /** @@ -48,6 +60,49 @@ export default class TreeRegistry { */ unregister(tree) { this.trees.delete(tree); + + // Remove from derivedTrees mappings + this.derivedTrees.delete(tree); + for (const [, derivedSet] of this.derivedTrees) { + derivedSet.delete(tree); + } + } + + /** + * Get all trees derived from a given tree (recursively). + * + * @param {import('./SharedHashTree.js').default} tree - The parent tree + * @returns {Set} Set of all derived trees (direct and transitive) + */ + _getDerivedTrees(tree) { + const result = new Set(); + const directDerived = this.derivedTrees.get(tree); + + if (directDerived) { + for (const derived of directDerived) { + result.add(derived); + // Recursively get trees derived from derived + for (const transitive of this._getDerivedTrees(derived)) { + result.add(transitive); + } + } + } + + return result; + } + + /** + * Check if targetTree is the same as or derived from sourceTree. + * + * @param {import('./SharedHashTree.js').default} sourceTree - The source/parent tree + * @param {import('./SharedHashTree.js').default} targetTree - The tree to check + * @returns {boolean} True if targetTree is sourceTree or derived from it + */ + _isTreeOrDerived(sourceTree, targetTree) { + if (sourceTree === targetTree) { + return true; + } + return this._getDerivedTrees(sourceTree).has(targetTree); } /** @@ -57,15 +112,23 @@ export default class TreeRegistry { * any necessary parent directories). If it exists, its metadata will be updated if changed. * Scheduling an upsert cancels any pending removal for the same resource path. * + * When sourceTree is specified, new resources will only be inserted into that tree and + * any trees derived from it. Updates to existing resources will still propagate to all + * trees that share the resource node. + * * @param {@ui5/fs/Resource} resource - Resource instance to upsert - * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed + * @param {number} [newIndexTimestamp] - Timestamp at which the provided resources have been indexed + * @param {import('./SharedHashTree.js').default} [sourceTree] - Tree that initiated this upsert + * (for controlling insert propagation) */ - scheduleUpsert(resource, newIndexTimestamp) { + scheduleUpsert(resource, newIndexTimestamp, sourceTree = null) { const resourcePath = resource.getOriginalPath(); - this.pendingUpserts.set(resourcePath, resource); + this.pendingUpserts.set(resourcePath, {resource, sourceTree}); // Cancel any pending removal for this path this.pendingRemovals.delete(resourcePath); - this.pendingTimestampUpdate = newIndexTimestamp; + if (newIndexTimestamp) { + this.pendingTimestampUpdate = newIndexTimestamp; + } } /** @@ -94,8 +157,8 @@ export default class TreeRegistry { * * Phase 2: Process upserts (inserts and updates) * - Group operations by parent directory for efficiency - * - Create missing parent directories as needed - * - Insert new resources or update existing ones + * - For inserts: only create in source tree and its derived trees + * - For updates: apply to all trees that share the resource node * - Skip updates for resources with unchanged metadata * - Track modified nodes to avoid duplicate updates to shared nodes * @@ -201,9 +264,9 @@ export default class TreeRegistry { } // 2. Handle upserts - group by directory - const upsertsByDir = new Map(); // parentPath -> [{resourceName, resource, fullPath}] + const upsertsByDir = new Map(); // parentPath -> [{resourceName, resource, fullPath, sourceTree}] - for (const [resourcePath, resource] of this.pendingUpserts) { + for (const [resourcePath, {resource, sourceTree}] of this.pendingUpserts) { const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); const resourceName = parts[parts.length - 1]; const parentPath = parts.slice(0, -1).join(path.sep); @@ -211,29 +274,41 @@ export default class TreeRegistry { if (!upsertsByDir.has(parentPath)) { upsertsByDir.set(parentPath, []); } - upsertsByDir.get(parentPath).push({resourceName, resource, fullPath: resourcePath}); + upsertsByDir.get(parentPath).push({resourceName, resource, fullPath: resourcePath, sourceTree}); } // Apply upserts for (const [parentPath, upserts] of upsertsByDir) { for (const tree of this.trees) { - // Ensure parent directory exists + // Check if parent directory exists in this tree let parentNode = tree._findNode(parentPath); - if (!parentNode) { - parentNode = this._ensureDirectoryPath( - tree, parentPath.split(path.sep).filter((p) => p.length > 0)); - } - - if (parentNode.type !== "directory") { - continue; - } let dirModified = false; for (const upsert of upserts) { - let resourceNode = parentNode.children.get(upsert.resourceName); + let resourceNode = parentNode?.children?.get(upsert.resourceName); if (!resourceNode) { - // INSERT: Create new resource node + // INSERT: Check derivation rules + if (upsert.sourceTree !== null) { + // Source tree specified - only insert into source tree and its derived trees + if (!this._isTreeOrDerived(upsert.sourceTree, tree)) { + // This tree is not the source tree or derived from it - skip insert + continue; + } + } + // If sourceTree is null, insert into all trees (backward compatibility) + + // Ensure parent directory exists (only for trees we're inserting into) + if (!parentNode) { + parentNode = this._ensureDirectoryPath( + tree, parentPath.split(path.sep).filter((p) => p.length > 0)); + } + + if (parentNode.type !== "directory") { + continue; + } + + // Create new resource node resourceNode = new TreeNode(upsert.resourceName, "resource", { integrity: await upsert.resource.getIntegrity(), lastModified: upsert.resource.getLastModified(), @@ -297,7 +372,7 @@ export default class TreeRegistry { } } - if (dirModified) { + if (dirModified && parentNode) { // Compute hashes for modified/new resources for (const upsert of upserts) { const resourceNode = parentNode.children.get(upsert.resourceName); diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index cb79e2755f1..364e48414e0 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -351,7 +351,6 @@ test.serial("Build theme.library.e project multiple times", async (t) => { assertions: { projects: {"theme.library.e": { skippedTasks: ["buildThemes"] - // NOTE: buildThemes currently gets NOT skipped -> TODO: fix }}, } }); diff --git a/packages/project/test/lib/build/cache/index/SharedHashTree.js b/packages/project/test/lib/build/cache/index/SharedHashTree.js index 78a7adfbe94..d01073e21d0 100644 --- a/packages/project/test/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/test/lib/build/cache/index/SharedHashTree.js @@ -50,6 +50,170 @@ test("SharedHashTree - creates tree with resources", (t) => { t.true(tree.hasPath("b.js"), "Should have b.js"); }); +// ============================================================================ +// SharedHashTree fromCache Tests +// ============================================================================ + +test("SharedHashTree.fromCache - restores tree from cache data", (t) => { + const registry = new TreeRegistry(); + + // Create original tree + const tree1 = new SharedHashTree([ + {path: "a.js", integrity: "hash-a", size: 100, lastModified: 1000, inode: 1}, + {path: "b.js", integrity: "hash-b", size: 200, lastModified: 2000, inode: 2} + ], registry); + + // Serialize tree + const cacheData = tree1.toCacheObject(); + + // Restore from cache + const registry2 = new TreeRegistry(); + const tree2 = SharedHashTree.fromCache(cacheData, registry2); + + t.truthy(tree2, "Should create tree from cache"); + t.is(tree2.getRootHash(), tree1.getRootHash(), "Root hash should match"); + t.true(tree2.hasPath("a.js"), "Should have a.js"); + t.true(tree2.hasPath("b.js"), "Should have b.js"); +}); + +test("SharedHashTree.fromCache - registers with provided registry", (t) => { + const registry1 = new TreeRegistry(); + const tree1 = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"} + ], registry1); + + const cacheData = tree1.toCacheObject(); + + const registry2 = new TreeRegistry(); + t.is(registry2.getTreeCount(), 0, "Registry should be empty initially"); + + SharedHashTree.fromCache(cacheData, registry2); + + t.is(registry2.getTreeCount(), 1, "Tree should be registered with new registry"); +}); + +test("SharedHashTree.fromCache - throws on unsupported version", (t) => { + const registry = new TreeRegistry(); + + const invalidCacheData = { + version: 999, + root: { + type: "directory", + hash: "some-hash", + children: {} + } + }; + + const error = t.throws(() => { + SharedHashTree.fromCache(invalidCacheData, registry); + }, { + instanceOf: Error + }); + + t.is(error.message, "Unsupported version: 999", "Should throw error for unsupported version"); +}); + +test("SharedHashTree.fromCache - preserves tree structure", (t) => { + const registry = new TreeRegistry(); + + // Create tree with nested structure + const tree1 = new SharedHashTree([ + {path: "src/components/Button.js", integrity: "hash-button", size: 300, lastModified: 3000, inode: 3}, + {path: "src/utils/helper.js", integrity: "hash-helper", size: 400, lastModified: 4000, inode: 4}, + {path: "test/button.test.js", integrity: "hash-test", size: 500, lastModified: 5000, inode: 5} + ], registry); + + const cacheData = tree1.toCacheObject(); + + const registry2 = new TreeRegistry(); + const tree2 = SharedHashTree.fromCache(cacheData, registry2); + + // Verify all paths exist + t.true(tree2.hasPath("src/components/Button.js"), "Should have Button.js"); + t.true(tree2.hasPath("src/utils/helper.js"), "Should have helper.js"); + t.true(tree2.hasPath("test/button.test.js"), "Should have test file"); + + // Verify structure matches + const paths1 = tree1.getResourcePaths().sort(); + const paths2 = tree2.getResourcePaths().sort(); + t.deepEqual(paths2, paths1, "Resource paths should match"); +}); + +test("SharedHashTree.fromCache - preserves resource metadata", (t) => { + const registry = new TreeRegistry(); + + const tree1 = new SharedHashTree([ + {path: "file.js", integrity: "hash-abc123", size: 12345, lastModified: 9999, inode: 7777} + ], registry); + + const cacheData = tree1.toCacheObject(); + + const registry2 = new TreeRegistry(); + const tree2 = SharedHashTree.fromCache(cacheData, registry2); + + const node1 = tree1.root.children.get("file.js"); + const node2 = tree2.root.children.get("file.js"); + + t.is(node2.integrity, node1.integrity, "Should preserve integrity"); + t.is(node2.size, node1.size, "Should preserve size"); + t.is(node2.lastModified, node1.lastModified, "Should preserve lastModified"); + t.is(node2.inode, node1.inode, "Should preserve inode"); +}); + +test("SharedHashTree.fromCache - accepts indexTimestamp option", (t) => { + const registry = new TreeRegistry(); + + const tree1 = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"} + ], registry, {indexTimestamp: 5000}); + + const cacheData = tree1.toCacheObject(); + + const registry2 = new TreeRegistry(); + const tree2 = SharedHashTree.fromCache(cacheData, registry2, {indexTimestamp: 5000}); + + t.is(tree2.getIndexTimestamp(), 5000, "Should accept and use indexTimestamp option"); +}); + +test("SharedHashTree.fromCache - restored tree can be modified", async (t) => { + const registry = new TreeRegistry(); + + const tree1 = new SharedHashTree([ + {path: "a.js", integrity: "hash-a"} + ], registry); + + const cacheData = tree1.toCacheObject(); + + const registry2 = new TreeRegistry(); + const tree2 = SharedHashTree.fromCache(cacheData, registry2); + + const originalHash = tree2.getRootHash(); + + // Modify restored tree + await tree2.upsertResources([ + createMockResource("b.js", "hash-b", Date.now(), 1024, 1) + ], Date.now()); + await registry2.flush(); + + const newHash = tree2.getRootHash(); + t.not(newHash, originalHash, "Hash should change after modification"); + t.true(tree2.hasPath("b.js"), "Should have new resource"); +}); + +test("SharedHashTree.fromCache - handles empty tree", (t) => { + const registry = new TreeRegistry(); + + const tree1 = new SharedHashTree([], registry); + const cacheData = tree1.toCacheObject(); + + const registry2 = new TreeRegistry(); + const tree2 = SharedHashTree.fromCache(cacheData, registry2); + + t.truthy(tree2, "Should create tree from empty cache"); + t.is(tree2.getResourcePaths().length, 0, "Should have no resources"); + t.truthy(tree2.getRootHash(), "Should have root hash even when empty"); +}); + // ============================================================================ // SharedHashTree upsertResources Tests // ============================================================================ @@ -497,13 +661,13 @@ test("SharedHashTree - registry tracks per-tree statistics", async (t) => { const result = await registry.flush(); t.is(result.treeStats.size, 2, "Should have stats for 2 trees"); - // Each tree sees additions for resources added by any tree (since all trees get all resources) + // Each tree only sees additions for resources added to itself (not to other independent trees) const stats1 = result.treeStats.get(tree1); const stats2 = result.treeStats.get(tree2); - // Both c.js and d.js are added to both trees - t.deepEqual(stats1.added.sort(), ["c.js", "d.js"], "Tree1 should see both additions"); - t.deepEqual(stats2.added.sort(), ["c.js", "d.js"], "Tree2 should see both additions"); + // c.js is only added to tree1, d.js is only added to tree2 + t.deepEqual(stats1.added.sort(), ["c.js"], "Tree1 should see c.js addition"); + t.deepEqual(stats2.added.sort(), ["d.js"], "Tree2 should see d.js addition"); }); test("SharedHashTree - unregister removes tree from coordination", async (t) => { diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js index ff110d0d6fc..d4e116a7cfd 100644 --- a/packages/project/test/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -232,7 +232,7 @@ test("deriveTree - shared nodes are the same reference", (t) => { t.is(file1, file2, "Shared resource nodes should be same reference"); }); -test("deriveTree - updates to shared nodes visible in all trees", async (t) => { +test("deriveTree - updates to shared nodes visible in all sub-trees", async (t) => { const registry = new TreeRegistry(); const resources = [ {path: "shared/file.js", integrity: "original"} @@ -257,6 +257,26 @@ test("deriveTree - updates to shared nodes visible in all trees", async (t) => { t.is(node2Before.integrity, "updated", "Tree2 node should be updated (same reference)"); }); +test("deriveTree - updates to sub-tree nodes are not visible in parents", async (t) => { + const registry = new TreeRegistry(); + const sharedResources = [ + {path: "shared/file.js", integrity: "original"} + ]; + const uniqueResources = [ + {path: "unique/file.js", integrity: "original"} + ]; + + const tree1 = new SharedHashTree(sharedResources, registry); + const tree2 = tree1.deriveTree(uniqueResources); + + // Update via tree2.upsertResources to ensure it's scoped to tree2 + await tree2.upsertResources([createMockResource("unique/file.js", "updated", Date.now(), 1024, 555)], Date.now()); + await registry.flush(); + + t.deepEqual(tree1.getResourcePaths(), ["/shared/file.js"], "Parent tree should not have unique resource"); + t.is(tree2.getResourceByPath("/unique/file.js").integrity, "updated", "Derived tree should see its own update"); +}); + test("deriveTree - multiple levels of derivation", async (t) => { const registry = new TreeRegistry(); From 42a393b10f2c3a213c744b5c8b80bb96f516bdaf Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 15:44:47 +0100 Subject: [PATCH 122/223] test(project): Adjust test cases for .library changes --- .../library.d/main/src/library/d/.library | 2 +- .../project/test/fixtures/library.d/ui5.yaml | 1 + .../test/lib/build/BuildServer.integration.js | 35 ++++++++++++++----- .../lib/build/ProjectBuilder.integration.js | 28 ++++++++++----- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/project/test/fixtures/library.d/main/src/library/d/.library b/packages/project/test/fixtures/library.d/main/src/library/d/.library index 53c2d14c9d6..21251d1bbba 100644 --- a/packages/project/test/fixtures/library.d/main/src/library/d/.library +++ b/packages/project/test/fixtures/library.d/main/src/library/d/.library @@ -3,7 +3,7 @@ library.d SAP SE - Some fancy copyright + ${copyright} ${version} Library D diff --git a/packages/project/test/fixtures/library.d/ui5.yaml b/packages/project/test/fixtures/library.d/ui5.yaml index a47c1f64c3d..9d1317fba3f 100644 --- a/packages/project/test/fixtures/library.d/ui5.yaml +++ b/packages/project/test/fixtures/library.d/ui5.yaml @@ -3,6 +3,7 @@ specVersion: "2.3" type: library metadata: name: library.d + copyright: Some fancy copyright resources: configuration: paths: diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 3bbac3fa8c9..04ae31c1e7d 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -99,30 +99,47 @@ test.serial("Serve application.a, request library resource", async (t) => { // Change a source file in library.a const changedFilePath = `${fixtureTester.fixturePath}/node_modules/collection/library.a/src/library/a/.library`; - await fs.appendFile(changedFilePath, `\n\n`); + await fs.writeFile( + changedFilePath, + (await fs.readFile(changedFilePath, {encoding: "utf8"})).replace( + `Library A`, + `Library A (updated #1)` + ) + ); await setTimeout(500); // Wait for the file watcher to detect and propagate the change // #3 request with cache and changes - const res = await fixtureTester.requestResource("/resources/library/a/.library", { + const dotLibraryResource = await fixtureTester.requestResource("/resources/library/a/.library", { projects: { "library.a": { skippedTasks: [ - "enhanceManifest", "escapeNonAsciiCharacters", "minify", - // Note: replaceCopyright is skipped because no copyright is configured in the project "replaceBuildtime", - "replaceCopyright", - "replaceVersion", ] } } }); - // Check whether the changed file is in the destPath - const servedFileContent = await res.getString(); - t.true(servedFileContent.includes(``), "Resource contains changed file content"); + // Check whether the changed file is served + const servedFileContent = await dotLibraryResource.getString(); + t.true( + servedFileContent.includes(`Library A (updated #1)`), + "Resource contains changed file content" + ); + + // #4 request with cache (no changes) + const manifestResource = await fixtureTester.requestResource("/resources/library/a/manifest.json", { + projects: {} + }); + + // Check whether the manifest is served correctly with changed .library content reflected + const manifestContent = JSON.parse(await manifestResource.getString()); + t.is( + manifestContent["sap.app"]["description"], "Library A (updated #1)", + "Manifest reflects changed .library content" + ); }); function getFixturePath(fixtureName) { diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 364e48414e0..1fda39aa87b 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -204,8 +204,8 @@ test.serial("Build library.d project multiple times", async (t) => { await fs.writeFile( changedFilePath, (await fs.readFile(changedFilePath, {encoding: "utf8"})).replace( - `Some fancy copyright`, - `Some new fancy copyright` + `Library D`, + `Library D (updated #1)` ) ); @@ -213,21 +213,29 @@ test.serial("Build library.d project multiple times", async (t) => { await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { - projects: {"library.d": {}} + projects: {"library.d": { + skippedTasks: [ + "buildThemes", + "escapeNonAsciiCharacters", + "minify", + "replaceBuildtime", + ] + }} } }); - // Check whether the changed file is in the destPath + // Check whether the changes are in the destPath const builtFileContent = await fs.readFile(`${destPath}/resources/library/d/.library`, {encoding: "utf8"}); t.true( - builtFileContent.includes(`Some new fancy copyright`), + builtFileContent.includes(`Library D (updated #1)`), "Build dest contains changed file content" ); - // Check whether the updated copyright replacement took place - const builtSomeJsContent = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + + // Check whether the manifest.json was updated with the new documentation + const manifestContent = await fs.readFile(`${destPath}/resources/library/d/manifest.json`, {encoding: "utf8"}); t.true( - builtSomeJsContent.includes(`Some new fancy copyright`), - "Build dest contains updated copyright in some.js" + manifestContent.includes(`"Library D (updated #1)"`), + "Build dest contains updated description in manifest.json" ); // #4 build (with cache, no changes) @@ -237,6 +245,8 @@ test.serial("Build library.d project multiple times", async (t) => { projects: {} } }); + + // TODO: Change copyright in ui5.yaml and expect that a full rebuild is triggered }); test.serial("Build theme.library.e project multiple times", async (t) => { From 70c06c3ebe102bad50347da7172fee033d1e931f Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 15:45:36 +0100 Subject: [PATCH 123/223] fix: Ensure dot-file matching with micromatch Fixes update issues with .library --- packages/fs/lib/adapters/AbstractAdapter.js | 2 +- packages/project/lib/build/cache/ResourceRequestManager.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/fs/lib/adapters/AbstractAdapter.js b/packages/fs/lib/adapters/AbstractAdapter.js index 4d2387de80b..9e0ee367cbb 100644 --- a/packages/fs/lib/adapters/AbstractAdapter.js +++ b/packages/fs/lib/adapters/AbstractAdapter.js @@ -96,7 +96,7 @@ class AbstractAdapter extends AbstractReaderWriter { * @returns {boolean} True if path is excluded, otherwise false */ _isPathExcluded(virPath) { - return micromatch(virPath, this._excludes).length > 0; + return micromatch(virPath, this._excludes, {dot: true}).length > 0; } /** diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 21402f9fdf7..1affba69208 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -274,7 +274,9 @@ class ResourceRequestManager { matchedResources.push(value); } } else { - const globMatches = micromatch(resourcePaths, value); + const globMatches = micromatch(resourcePaths, value, { + dot: true + }); for (const match of globMatches) { if (!matchedResources.includes(match)) { matchedResources.push(match); From 9579961845146f5170eab83534a79d5e1fec290d Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 15:48:26 +0100 Subject: [PATCH 124/223] test(project): Add test case for ui5.yaml changes --- .../lib/build/ProjectBuilder.integration.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 1fda39aa87b..4d3c73293fd 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -246,7 +246,23 @@ test.serial("Build library.d project multiple times", async (t) => { } }); - // TODO: Change copyright in ui5.yaml and expect that a full rebuild is triggered + // Update copyright in ui5.yaml (should trigger a full rebuild of the project) + const ui5YamlPath = `${fixtureTester.fixturePath}/ui5.yaml`; + await fs.writeFile( + ui5YamlPath, + (await fs.readFile(ui5YamlPath, {encoding: "utf8"})).replace( + "copyright: Some fancy copyright", + "copyright: Some updated fancy copyright" + ) + ); + + // #5 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"library.d": {}} + } + }); }); test.serial("Build theme.library.e project multiple times", async (t) => { From 84f9f2c0f672807aa1973a58b3745f499da7b4e6 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 17:11:22 +0100 Subject: [PATCH 125/223] fix(project): Handle BuildServer race condition when changing files --- packages/project/lib/build/BuildServer.js | 9 ++++- .../test/lib/build/BuildServer.integration.js | 40 ++++++++++++++++--- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index b7bc16b6e63..47bfb7f8f80 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -114,7 +114,14 @@ class BuildServer extends EventEmitter { }); watchHandler.on("batchedChanges", (changes) => { log.verbose(`Received batched source changes for projects: ${[...changes.keys()].join(", ")}`); - this.#batchResourceChanges(changes); + if (this.#activeBuild) { + log.verbose("Waiting for active build to finish before processing batched source changes"); + this.#activeBuild.finally(() => { + this.#batchResourceChanges(changes); + }); + } else { + this.#batchResourceChanges(changes); + } }); } diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 04ae31c1e7d..5b2e0014da7 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -37,9 +37,40 @@ test.afterEach.always(async (t) => { process.off("ui5.project-build-status", t.context.projectBuildStatusEventStub); }); +// Note: This test should be the first test to run, as it covers initial build scenarios, which are not reproducible +// once the BuildServer has been started and built a project at least once. +// This is independent of caching on file-system level, which is isolated per test via tmp folders. +test.serial.only("Serve application.a, initial file changes", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + await fixtureTester.serveProject(); + + // Directly change a source file in application.a before requesting it + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("initial change");\n`); + + // Request the changed resource immediately + const resourceRequestPromise = fixtureTester.requestResource("/test.js", { + projects: { + "application.a": {} + } + }); + // Directly change the source file again, which should abort the current build and trigger a new one + await fs.appendFile(changedFilePath, `\ntest("second change");\n`); + await fs.appendFile(changedFilePath, `\ntest("third change");\n`); + + // Wait for the resource to be served + const resource = await resourceRequestPromise; + + // Check whether the change is reflected + const servedFileContent = await resource.getString(); + t.true(servedFileContent.includes(`test("initial change");`), "Resource contains initial changed file content"); + t.true(servedFileContent.includes(`test("second change");`), "Resource contains second changed file content"); + t.true(servedFileContent.includes(`test("third change");`), "Resource contains third changed file content"); +}); + test.serial("Serve application.a, request application resource", async (t) => { - const fixtureTester = new FixtureTester(t, "application.a"); - t.context.fixtureTester = fixtureTester; + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); // #1 request with empty cache await fixtureTester.serveProject(); @@ -81,8 +112,7 @@ test.serial("Serve application.a, request application resource", async (t) => { }); test.serial("Serve application.a, request library resource", async (t) => { - const fixtureTester = new FixtureTester(t, "application.a"); - t.context.fixtureTester = fixtureTester; + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); // #1 request with empty cache await fixtureTester.serveProject(); @@ -147,7 +177,7 @@ function getFixturePath(fixtureName) { } function getTmpPath(folderName) { - return fileURLToPath(new URL(`../../tmp/ProjectServer/${folderName}`, import.meta.url)); + return fileURLToPath(new URL(`../../tmp/BuildServer/${folderName}`, import.meta.url)); } async function rmrf(dirPath) { From bf6fb97d8aebc889df386839733403822fbe6427 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 17:25:03 +0100 Subject: [PATCH 126/223] test(project): Remove test.serial.only --- packages/project/test/lib/build/BuildServer.integration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 5b2e0014da7..88df8a8292b 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -40,7 +40,7 @@ test.afterEach.always(async (t) => { // Note: This test should be the first test to run, as it covers initial build scenarios, which are not reproducible // once the BuildServer has been started and built a project at least once. // This is independent of caching on file-system level, which is isolated per test via tmp folders. -test.serial.only("Serve application.a, initial file changes", async (t) => { +test.serial("Serve application.a, initial file changes", async (t) => { const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); await fixtureTester.serveProject(); From cc8d8a126c8b4fae38f611b38e0ae471b85ec5b0 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 26 Jan 2026 17:40:59 +0100 Subject: [PATCH 127/223] deps: Fix depcheck issues --- package-lock.json | 165 ++++++++++++++++++++++++---------- packages/project/package.json | 2 +- packages/server/package.json | 1 - 3 files changed, 120 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index a43aeae5ee1..8112c408236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4757,6 +4757,31 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/append-transform": { "version": "2.0.0", "dev": true, @@ -4878,8 +4903,6 @@ }, "node_modules/async-mutex": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", "license": "MIT", "dependencies": { "tslib": "^2.4.0" @@ -5087,6 +5110,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "dev": true, @@ -5599,6 +5634,42 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/chownr": { "version": "3.0.0", "license": "BlueOak-1.0.0", @@ -9160,6 +9231,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "dev": true, @@ -11008,6 +11091,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-bundled": { "version": "5.0.0", "license": "ISC", @@ -12849,6 +12941,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "dev": true, @@ -12941,46 +13057,6 @@ "node": ">=4" } }, - "node_modules/replacestream": { - "version": "4.0.3", - "license": "BSD-3-Clause", - "dependencies": { - "escape-string-regexp": "^1.0.3", - "object-assign": "^4.0.1", - "readable-stream": "^2.0.2" - } - }, - "node_modules/replacestream/node_modules/escape-string-regexp": { - "version": "1.0.5", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/replacestream/node_modules/readable-stream": { - "version": "2.3.8", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/replacestream/node_modules/safe-buffer": { - "version": "5.1.2", - "license": "MIT" - }, - "node_modules/replacestream/node_modules/string_decoder": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "dev": true, @@ -14711,8 +14787,6 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tuf-js": { @@ -16042,6 +16116,7 @@ "js-yaml": "^4.1.1", "lockfile": "^1.0.4", "make-fetch-happen": "^15.0.5", + "micromatch": "^4.0.8", "node-stream-zip": "^1.15.0", "pacote": "^21.0.4", "pretty-hrtime": "^1.0.3", @@ -16049,7 +16124,6 @@ "read-pkg": "^10.0.0", "resolve": "^1.22.10", "semver": "^7.7.2", - "ssri": "^13.0.0", "xml2js": "^0.6.2", "yesno": "^0.4.0" }, @@ -16116,7 +16190,6 @@ "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", - "replacestream": "^4.0.3", "router": "^2.2.0", "spdy": "^4.0.2", "yesno": "^0.4.0" diff --git a/packages/project/package.json b/packages/project/package.json index cfc354986b2..e0dec9180dd 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -70,6 +70,7 @@ "js-yaml": "^4.1.1", "lockfile": "^1.0.4", "make-fetch-happen": "^15.0.5", + "micromatch": "^4.0.8", "node-stream-zip": "^1.15.0", "pacote": "^21.0.4", "pretty-hrtime": "^1.0.3", @@ -77,7 +78,6 @@ "read-pkg": "^10.0.0", "resolve": "^1.22.10", "semver": "^7.7.2", - "ssri": "^13.0.0", "xml2js": "^0.6.2", "yesno": "^0.4.0" }, diff --git a/packages/server/package.json b/packages/server/package.json index 86c44da9949..b06670dc8bd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -101,7 +101,6 @@ "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", - "replacestream": "^4.0.3", "router": "^2.2.0", "spdy": "^4.0.2", "yesno": "^0.4.0" From 8673f76cb0cf936bd31cd04fbe92e57884a241a3 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 16:26:53 +0100 Subject: [PATCH 128/223] test(project): Update ProjectBuildCache and TaskBuildCache tests --- .../test/lib/build/cache/BuildTaskCache.js | 760 +++++++----------- .../test/lib/build/cache/ProjectBuildCache.js | 562 +++++++------ 2 files changed, 604 insertions(+), 718 deletions(-) diff --git a/packages/project/test/lib/build/cache/BuildTaskCache.js b/packages/project/test/lib/build/cache/BuildTaskCache.js index d8efcfdaf82..d48169552fd 100644 --- a/packages/project/test/lib/build/cache/BuildTaskCache.js +++ b/packages/project/test/lib/build/cache/BuildTaskCache.js @@ -2,42 +2,34 @@ import test from "ava"; import sinon from "sinon"; import BuildTaskCache from "../../../../lib/build/cache/BuildTaskCache.js"; -// Helper to create mock Resource instances -function createMockResource(path, integrity = "test-hash", lastModified = 1000, size = 100, inode = 1) { +// Helper to create mock readers +function createMockReader(resources = []) { + const resourceMap = new Map(resources.map((r) => [r.getPath(), r])); return { - getOriginalPath: () => path, - getPath: () => path, - getIntegrity: async () => integrity, - getLastModified: () => lastModified, - getSize: async () => size, - getInode: () => inode, - getBuffer: async () => Buffer.from("test content"), - getStream: () => null + byGlob: sinon.stub().callsFake(async (pattern) => { + // Simple pattern matching for tests + if (pattern === "/**/*") { + return Array.from(resourceMap.values()); + } + return resources.filter((r) => r.getPath().includes(pattern.replace(/[*]/g, ""))); + }), + byPath: sinon.stub().callsFake(async (path) => { + return resourceMap.get(path) || null; + }) }; } -// Helper to create mock Reader (project or dependency) -function createMockReader(resources = new Map()) { +// Helper to create mock resources +function createMockResource(path, content = "test content", hash = null) { + const actualHash = hash || `hash-${path}`; return { - byPath: sinon.stub().callsFake(async (path) => { - return resources.get(path) || null; - }), - byGlob: sinon.stub().callsFake(async (patterns) => { - // Simple mock: return all resources that match the pattern - const allPaths = Array.from(resources.keys()); - const results = []; - for (const path of allPaths) { - // Very simplified matching - just check if pattern is substring - const patternArray = Array.isArray(patterns) ? patterns : [patterns]; - for (const pattern of patternArray) { - if (pattern === "/**/*" || path.includes(pattern.replace(/\*/g, ""))) { - results.push(resources.get(path)); - break; - } - } - } - return results; - }) + getPath: () => path, + getOriginalPath: () => path, + getBuffer: async () => Buffer.from(content), + getIntegrity: async () => actualHash, + getLastModified: () => 1000, + getSize: async () => content.length, + getInode: () => 1 }; } @@ -45,600 +37,458 @@ test.afterEach.always(() => { sinon.restore(); }); -// ===== CONSTRUCTOR TESTS ===== +// ===== CREATION AND INITIALIZATION TESTS ===== -test("Create BuildTaskCache without metadata", (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("Create BuildTaskCache instance", (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); t.truthy(cache, "BuildTaskCache instance created"); - t.is(cache.getTaskName(), "myTask", "Task name is correct"); + t.is(cache.getTaskName(), "testTask", "Task name matches"); + t.is(cache.getSupportsDifferentialUpdates(), false, "Differential updates disabled"); +}); + +test("Create with differential updates enabled", (t) => { + const cache = new BuildTaskCache("test.project", "testTask", true); + + t.is(cache.getSupportsDifferentialUpdates(), true, "Differential updates enabled"); }); -test("Create BuildTaskCache with metadata", (t) => { - const metadata = { +test("fromCache: restore BuildTaskCache from cached data", (t) => { + const projectRequests = { requestSetGraph: { nodes: [], nextId: 1 - } + }, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false }; - const cache = new BuildTaskCache("test.project", "myTask", "build-sig", metadata); - - t.truthy(cache, "BuildTaskCache instance created with metadata"); - t.is(cache.getTaskName(), "myTask", "Task name is correct"); -}); - -test("Create BuildTaskCache with complex metadata", (t) => { - const metadata = { + const dependencyRequests = { requestSetGraph: { - nodes: [ - { - id: 1, - parent: null, - addedRequests: ["path:/test.js", "patterns:[\"**/*.js\"]"] - } - ], - nextId: 2 - } + nodes: [], + nextId: 1 + }, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false }; - const cache = new BuildTaskCache("test.project", "myTask", "build-sig", metadata); + const cache = BuildTaskCache.fromCache("test.project", "testTask", false, + projectRequests, dependencyRequests); - t.truthy(cache, "BuildTaskCache created with complex metadata"); + t.truthy(cache, "Cache restored from cached data"); + t.is(cache.getTaskName(), "testTask", "Task name preserved"); + t.is(cache.getSupportsDifferentialUpdates(), false, "Differential updates setting preserved"); }); // ===== METADATA ACCESS TESTS ===== -test("getTaskName returns correct task name", (t) => { - const cache = new BuildTaskCache("test.project", "mySpecialTask", "build-sig"); +test("getTaskName: returns task name", (t) => { + const cache = new BuildTaskCache("test.project", "myTask", false); - t.is(cache.getTaskName(), "mySpecialTask", "Returns correct task name"); + t.is(cache.getTaskName(), "myTask", "Task name returned"); }); -test("getPossibleStageSignatures with no cached signatures", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("getSupportsDifferentialUpdates: returns correct value", (t) => { + const cache1 = new BuildTaskCache("test.project", "task1", false); + const cache2 = new BuildTaskCache("test.project", "task2", true); - const signatures = await cache.getPossibleStageSignatures(); - - t.deepEqual(signatures, [], "Returns empty array when no requests recorded"); + t.false(cache1.getSupportsDifferentialUpdates(), "Returns false when disabled"); + t.true(cache2.getSupportsDifferentialUpdates(), "Returns true when enabled"); }); -test("getPossibleStageSignatures throws when resourceIndex missing", async (t) => { - const metadata = { - requestSetGraph: { - nodes: [ - { - id: 1, - parent: null, - addedRequests: ["path:/test.js"] - } - ], - nextId: 2 - } - }; - - const cache = new BuildTaskCache("test.project", "myTask", "build-sig", metadata); +test("hasNewOrModifiedCacheEntries: initially true for new instance", (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - await t.throwsAsync( - async () => { - await cache.getPossibleStageSignatures(); - }, - { - message: /Resource index missing for request set ID/ - }, - "Throws error when resource index is missing" - ); + // A new instance has new entries that need to be written + t.true(cache.hasNewOrModifiedCacheEntries(), "New instance has entries to write"); }); -// ===== SIGNATURE CALCULATION TESTS ===== - -test("calculateSignature with simple path requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("hasNewOrModifiedCacheEntries: true after recording requests", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")], - ["/app.js", createMockResource("/app.js", "hash2")] - ]); - - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + const resource = createMockResource("/test.js"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); const projectRequests = { - paths: new Set(["/test.js", "/app.js"]), + paths: new Set(["/test.js"]), patterns: new Set() }; - const signature = await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, undefined, projectReader, dependencyReader); - t.truthy(signature, "Signature generated"); - t.is(typeof signature, "string", "Signature is a string"); + t.true(cache.hasNewOrModifiedCacheEntries(), "Has new entries after recording"); }); -test("calculateSignature with pattern requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +// ===== SIGNATURE TESTS ===== - const resources = new Map([ - ["/src/test.js", createMockResource("/src/test.js", "hash1")], - ["/src/app.js", createMockResource("/src/app.js", "hash2")] - ]); +test("getProjectIndexSignatures: returns signatures after recording", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + const resource = createMockResource("/test.js"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); const projectRequests = { - paths: new Set(), - patterns: new Set(["/**/*.js"]) + paths: new Set(["/test.js"]), + patterns: new Set() }; - const signature = await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, undefined, projectReader, dependencyReader); - t.truthy(signature, "Signature generated for pattern request"); -}); - -test("calculateSignature with dependency requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + const signatures = cache.getProjectIndexSignatures(); - const projectResources = new Map([ - ["/app.js", createMockResource("/app.js", "hash1")] - ]); + t.true(Array.isArray(signatures), "Returns array"); + t.true(signatures.length > 0, "Has at least one signature"); + t.is(typeof signatures[0], "string", "Signature is a string"); +}); - const depResources = new Map([ - ["/lib/dep.js", createMockResource("/lib/dep.js", "hash-dep")] - ]); +test("getDependencyIndexSignatures: returns signatures after recording", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const projectReader = createMockReader(projectResources); - const dependencyReader = createMockReader(depResources); + const projectResource = createMockResource("/test.js"); + const depResource = createMockResource("/dep.js"); + const projectReader = createMockReader([projectResource]); + const dependencyReader = createMockReader([depResource]); const projectRequests = { - paths: new Set(["/app.js"]), + paths: new Set(["/test.js"]), patterns: new Set() }; const dependencyRequests = { - paths: new Set(["/lib/dep.js"]), + paths: new Set(["/dep.js"]), patterns: new Set() }; - const signature = await cache.calculateSignature( - projectRequests, - dependencyRequests, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, dependencyRequests, projectReader, dependencyReader); - t.truthy(signature, "Signature generated with dependency requests"); + const signatures = cache.getDependencyIndexSignatures(); + + t.true(Array.isArray(signatures), "Returns array"); + t.true(signatures.length > 0, "Has at least one signature"); + t.is(typeof signatures[0], "string", "Signature is a string"); }); -test("calculateSignature returns same signature for same requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +// ===== REQUEST RECORDING TESTS ===== - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")] - ]); +test("recordRequests: handles project requests only", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + const resource = createMockResource("/test.js"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); const projectRequests = { paths: new Set(["/test.js"]), patterns: new Set() }; - const signature1 = await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - const signature2 = await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - t.is(signature1, signature2, "Same requests produce same signature"); + const [projectSig, depSig] = await cache.recordRequests( + projectRequests, undefined, projectReader, dependencyReader); + + t.is(typeof projectSig, "string", "Project signature returned"); + t.is(typeof depSig, "string", "Dependency signature returned"); + t.true(projectSig.length > 0, "Project signature not empty"); + t.true(depSig.length > 0, "Dependency signature not empty"); }); -test("calculateSignature with empty requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("recordRequests: handles both project and dependency requests", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const projectReader = createMockReader(new Map()); - const dependencyReader = createMockReader(new Map()); + const projectResource = createMockResource("/test.js"); + const depResource = createMockResource("/dep.js"); + const projectReader = createMockReader([projectResource]); + const dependencyReader = createMockReader([depResource]); const projectRequests = { - paths: new Set(), + paths: new Set(["/test.js"]), patterns: new Set() }; - const signature = await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + const dependencyRequests = { + paths: new Set(["/dep.js"]), + patterns: new Set() + }; - t.truthy(signature, "Signature generated even with no requests"); + const [projectSig, depSig] = await cache.recordRequests( + projectRequests, dependencyRequests, projectReader, dependencyReader); + + t.is(typeof projectSig, "string", "Project signature returned"); + t.is(typeof depSig, "string", "Dependency signature returned"); }); -// ===== RESOURCE MATCHING TESTS ===== +test("recordRequests: handles glob patterns", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); + + const resource1 = createMockResource("/src/test1.js"); + const resource2 = createMockResource("/src/test2.js"); + const projectReader = createMockReader([resource1, resource2]); + const dependencyReader = createMockReader([]); -test("matchesChangedResources: exact path match", (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + const projectRequests = { + paths: new Set(), + patterns: new Set(["/src/**/*.js"]) + }; - // Need to populate the cache with some requests first - // We'll use toCacheObject to verify the internal state - const result = cache.matchesChangedResources(["/test.js"], []); + const [projectSig, depSig] = await cache.recordRequests( + projectRequests, undefined, projectReader, dependencyReader); - // Without any recorded requests, should not match - t.false(result, "No match when no requests recorded"); + t.is(typeof projectSig, "string", "Project signature returned"); + t.is(typeof depSig, "string", "Dependency signature returned"); }); -test("matchesChangedResources: after recording requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("recordRequests: handles empty requests", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")] - ]); - - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + const projectReader = createMockReader([]); + const dependencyReader = createMockReader([]); const projectRequests = { - paths: new Set(["/test.js"]), + paths: new Set(), patterns: new Set() }; - // Record the request - await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - // Now check if it matches - t.true(cache.matchesChangedResources(["/test.js"], []), "Matches exact path"); - t.false(cache.matchesChangedResources(["/other.js"], []), "Doesn't match different path"); + const [projectSig, depSig] = await cache.recordRequests( + projectRequests, undefined, projectReader, dependencyReader); + + t.is(typeof projectSig, "string", "Project signature returned"); + t.is(typeof depSig, "string", "Dependency signature returned"); }); -test("matchesChangedResources: pattern matching", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +// ===== INDEX UPDATE TESTS ===== - const resources = new Map([ - ["/src/test.js", createMockResource("/src/test.js", "hash1")] - ]); +test("updateProjectIndices: processes changed resources", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + // First, record some requests + const resource = createMockResource("/test.js", "initial content"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); const projectRequests = { - paths: new Set(), - patterns: new Set(["**/*.js"]) + paths: new Set(["/test.js"]), + patterns: new Set() }; - await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, undefined, projectReader, dependencyReader); + + // Now update with changed resource + const updatedResource = createMockResource("/test.js", "updated content", "new-hash"); + const updatedReader = createMockReader([updatedResource]); + + const changed = await cache.updateProjectIndices(updatedReader, ["/test.js"]); - t.true(cache.matchesChangedResources(["/src/app.js"], []), "Pattern matches changed .js file"); - t.false(cache.matchesChangedResources(["/src/styles.css"], []), "Pattern doesn't match .css file"); + t.is(typeof changed, "boolean", "Returns boolean"); }); -test("matchesChangedResources: dependency path match", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("updateDependencyIndices: processes changed dependencies", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const depResources = new Map([ - ["/lib/dep.js", createMockResource("/lib/dep.js", "hash1")] - ]); + // First, record some requests + const projectResource = createMockResource("/test.js"); + const depResource = createMockResource("/dep.js", "initial"); + const projectReader = createMockReader([projectResource]); + const dependencyReader = createMockReader([depResource]); - const projectReader = createMockReader(new Map()); - const dependencyReader = createMockReader(depResources); + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; const dependencyRequests = { - paths: new Set(["/lib/dep.js"]), + paths: new Set(["/dep.js"]), patterns: new Set() }; - await cache.calculateSignature( - {paths: new Set(), patterns: new Set()}, - dependencyRequests, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, dependencyRequests, projectReader, dependencyReader); + + // Now update with changed dependency + const updatedDepResource = createMockResource("/dep.js", "updated", "new-dep-hash"); + const updatedDepReader = createMockReader([updatedDepResource]); - t.true(cache.matchesChangedResources([], ["/lib/dep.js"]), "Matches dependency path"); - t.false(cache.matchesChangedResources([], ["/lib/other.js"]), "Doesn't match different dependency"); + const changed = await cache.updateDependencyIndices(updatedDepReader, ["/dep.js"]); + + t.is(typeof changed, "boolean", "Returns boolean"); }); -test("matchesChangedResources: dependency pattern match", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("refreshDependencyIndices: refreshes all dependency indices", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const depResources = new Map([ - ["/lib/utils.js", createMockResource("/lib/utils.js", "hash1")] - ]); + // First, record some requests + const projectResource = createMockResource("/test.js"); + const depResource = createMockResource("/dep.js"); + const projectReader = createMockReader([projectResource]); + const dependencyReader = createMockReader([depResource]); - const projectReader = createMockReader(new Map()); - const dependencyReader = createMockReader(depResources); + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; const dependencyRequests = { - paths: new Set(), - patterns: new Set(["/lib/**/*.js"]) + paths: new Set(["/dep.js"]), + patterns: new Set() }; - await cache.calculateSignature( - {paths: new Set(), patterns: new Set()}, - dependencyRequests, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, dependencyRequests, projectReader, dependencyReader); - t.true(cache.matchesChangedResources([], ["/lib/helper.js"]), "Pattern matches changed dependency"); - t.false(cache.matchesChangedResources([], ["/other/file.js"]), "Pattern doesn't match outside path"); + // Refresh all indices - returns undefined when processing changes, or false if no requests + const result = await cache.refreshDependencyIndices(dependencyReader); + + t.true(result === undefined || result === false, "Returns undefined or false"); }); -test("matchesChangedResources: multiple patterns", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +// ===== DELTA TESTS (for differential updates) ===== - const resources = new Map([ - ["/src/app.js", createMockResource("/src/app.js", "hash1")] - ]); +test("getProjectIndexDeltas: returns deltas when enabled", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", true); - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + const resource = createMockResource("/test.js"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); const projectRequests = { - paths: new Set(), - patterns: new Set(["**/*.js", "**/*.css"]) + paths: new Set(["/test.js"]), + patterns: new Set() }; - await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, undefined, projectReader, dependencyReader); + + const deltas = cache.getProjectIndexDeltas(); - t.true(cache.matchesChangedResources(["/src/app.js"], []), "Matches .js file"); - t.true(cache.matchesChangedResources(["/src/styles.css"], []), "Matches .css file"); - t.false(cache.matchesChangedResources(["/src/image.png"], []), "Doesn't match .png file"); + t.true(deltas instanceof Map, "Returns Map"); }); -// ===== UPDATE INDICES TESTS ===== +test("getDependencyIndexDeltas: returns deltas when enabled", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", true); -test("updateIndices with no changes", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + const projectResource = createMockResource("/test.js"); + const depResource = createMockResource("/dep.js"); + const projectReader = createMockReader([projectResource]); + const dependencyReader = createMockReader([depResource]); - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")] - ]); + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + const dependencyRequests = { + paths: new Set(["/dep.js"]), + patterns: new Set() + }; - // First calculate signature to establish baseline - await cache.calculateSignature( - {paths: new Set(["/test.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + await cache.recordRequests(projectRequests, dependencyRequests, projectReader, dependencyReader); - // Update with no changed paths - await cache.updateIndices(new Set(), new Set(), projectReader, dependencyReader); + const deltas = cache.getDependencyIndexDeltas(); - t.pass("updateIndices completed with no changes"); + t.true(deltas instanceof Map, "Returns Map"); }); -test("updateIndices with changed resource", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); - - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")] - ]); +// ===== SERIALIZATION TESTS ===== - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); +test("toCacheObjects: returns cache objects", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - // First calculate signature - await cache.calculateSignature( - {paths: new Set(["/test.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + const resource = createMockResource("/test.js"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); - // Update the resource - resources.set("/test.js", createMockResource("/test.js", "hash2", 2000)); + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; - // Update indices - await cache.updateIndices(new Set(["/test.js"]), new Set(), projectReader, dependencyReader); + await cache.recordRequests(projectRequests, undefined, projectReader, dependencyReader); - t.pass("updateIndices completed with changed resource"); -}); + const [projectCache, dependencyCache] = cache.toCacheObjects(); -test("updateIndices with removed resource", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); - - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")], - ["/app.js", createMockResource("/app.js", "hash2")] - ]); - - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); - - // First calculate signature - await cache.calculateSignature( - {paths: new Set(["/test.js", "/app.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - // Remove one resource - resources.delete("/app.js"); - - // Update indices - this is a more complex scenario that involves internal ResourceIndex behavior - // For now, we test that it can be called (deeper testing would require mocking ResourceIndex internals) - try { - await cache.updateIndices(new Set(["/app.js"]), new Set(), projectReader, dependencyReader); - t.pass("updateIndices can be called with removed resource"); - } catch (err) { - // Expected in unit test environment - would work with real ResourceIndex - if (err.message.includes("removeResources is not a function")) { - t.pass("updateIndices attempted to handle removed resource (integration test needed)"); - } else { - throw err; - } - } + t.truthy(projectCache, "Project cache object exists"); + t.truthy(dependencyCache, "Dependency cache object exists"); + t.truthy(projectCache.requestSetGraph, "Has request set graph"); + t.true(Array.isArray(projectCache.rootIndices), "Has root indices array"); }); -// ===== SERIALIZATION TESTS ===== - -test("toCacheObject returns valid structure", (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("toCacheObjects: can restore from serialized data", async (t) => { + const cache1 = new BuildTaskCache("test.project", "testTask", false); - const cacheObject = cache.toCacheObject(); - - t.truthy(cacheObject, "Cache object created"); - t.truthy(cacheObject.requestSetGraph, "Contains requestSetGraph"); - t.truthy(cacheObject.requestSetGraph.nodes, "requestSetGraph has nodes"); - t.is(typeof cacheObject.requestSetGraph.nextId, "number", "requestSetGraph has nextId"); -}); + const resource = createMockResource("/test.js"); + const projectReader = createMockReader([resource]); + const dependencyReader = createMockReader([]); -test("toCacheObject after recording requests", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); - - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")] - ]); + const projectRequests = { + paths: new Set(["/test.js"]), + patterns: new Set() + }; - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); + await cache1.recordRequests(projectRequests, undefined, projectReader, dependencyReader); - await cache.calculateSignature( - {paths: new Set(["/test.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + const [projectCache, dependencyCache] = cache1.toCacheObjects(); - const cacheObject = cache.toCacheObject(); + // Restore from cache + const cache2 = BuildTaskCache.fromCache("test.project", "testTask", false, + projectCache, dependencyCache); - t.truthy(cacheObject.requestSetGraph, "Contains requestSetGraph"); - t.true(cacheObject.requestSetGraph.nodes.length > 0, "Has recorded nodes"); + t.truthy(cache2, "Cache restored"); + t.is(cache2.getTaskName(), "testTask", "Task name preserved"); }); -test("Round-trip serialization", async (t) => { - const cache1 = new BuildTaskCache("test.project", "myTask", "build-sig"); - - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")] - ]); +// ===== EDGE CASES ===== - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); +test("Create with empty project name", (t) => { + const cache = new BuildTaskCache("", "testTask", false); - await cache1.calculateSignature( - {paths: new Set(["/test.js"]), patterns: new Set(["**/*.js"])}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); + t.truthy(cache, "Cache created with empty project name"); + t.is(cache.getTaskName(), "testTask", "Task name still accessible"); +}); - const cacheObject = cache1.toCacheObject(); +test("Multiple recordRequests calls accumulate", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - // Create new cache from serialized data - const cache2 = new BuildTaskCache("test.project", "myTask", "build-sig", cacheObject); + const resource1 = createMockResource("/test1.js"); + const resource2 = createMockResource("/test2.js"); + const projectReader = createMockReader([resource1, resource2]); + const dependencyReader = createMockReader([]); - t.is(cache2.getTaskName(), "myTask", "Task name preserved"); - t.truthy(cache2.toCacheObject(), "Can serialize again"); -}); + // First request + const projectRequests1 = { + paths: new Set(["/test1.js"]), + patterns: new Set() + }; -// ===== EDGE CASES ===== + await cache.recordRequests(projectRequests1, undefined, projectReader, dependencyReader); -test("Create cache with special characters in names", (t) => { - const cache = new BuildTaskCache("test.project-123", "my:special:task", "build-sig"); + const sigsBefore = cache.getProjectIndexSignatures(); - t.is(cache.getTaskName(), "my:special:task", "Special characters in task name preserved"); -}); + // Second request with different resources + const projectRequests2 = { + paths: new Set(["/test2.js"]), + patterns: new Set() + }; -test("matchesChangedResources with empty arrays", (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); + await cache.recordRequests(projectRequests2, undefined, projectReader, dependencyReader); - const result = cache.matchesChangedResources([], []); + const sigsAfter = cache.getProjectIndexSignatures(); - t.false(result, "No matches with empty arrays"); + t.true(sigsAfter.length >= sigsBefore.length, "Signatures accumulated"); }); -test("calculateSignature with non-existent resource", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); +test("Handles non-existent resource paths", async (t) => { + const cache = new BuildTaskCache("test.project", "testTask", false); - const projectReader = createMockReader(new Map()); // Empty - resource doesn't exist - const dependencyReader = createMockReader(new Map()); + const projectReader = createMockReader([]); + const dependencyReader = createMockReader([]); const projectRequests = { paths: new Set(["/nonexistent.js"]), patterns: new Set() }; - // Should not throw, just handle gracefully - const signature = await cache.calculateSignature( - projectRequests, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - t.truthy(signature, "Signature generated even when resource doesn't exist"); -}); + const [projectSig, depSig] = await cache.recordRequests( + projectRequests, undefined, projectReader, dependencyReader); -test("Multiple calculateSignature calls create optimization", async (t) => { - const cache = new BuildTaskCache("test.project", "myTask", "build-sig"); - - const resources = new Map([ - ["/test.js", createMockResource("/test.js", "hash1")], - ["/app.js", createMockResource("/app.js", "hash2")] - ]); - - const projectReader = createMockReader(resources); - const dependencyReader = createMockReader(new Map()); - - // First request set - const sig1 = await cache.calculateSignature( - {paths: new Set(["/test.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - // Second request set that includes first - const sig2 = await cache.calculateSignature( - {paths: new Set(["/test.js", "/app.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}, - projectReader, - dependencyReader - ); - - t.truthy(sig1, "First signature generated"); - t.truthy(sig2, "Second signature generated"); - t.not(sig1, sig2, "Different request sets produce different signatures"); - - const cacheObject = cache.toCacheObject(); - t.true(cacheObject.requestSetGraph.nodes.length > 1, "Multiple request sets recorded"); + t.is(typeof projectSig, "string", "Still returns signature"); + t.is(typeof depSig, "string", "Still returns dependency signature"); }); diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 83527bb4c15..79e277ef98d 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -5,7 +5,7 @@ import ProjectBuildCache from "../../../../lib/build/cache/ProjectBuildCache.js" // Helper to create mock Project instances function createMockProject(name = "test.project", id = "test-project-id") { const stages = new Map(); - let currentStage = "source"; + let currentStage = {getId: () => "initial"}; let resultStageReader = null; // Create a reusable reader with both byGlob and byPath @@ -20,12 +20,13 @@ function createMockProject(name = "test.project", id = "test-project-id") { getSourceReader: sinon.stub().callsFake(() => createReader()), getReader: sinon.stub().callsFake(() => createReader()), getStage: sinon.stub().returns({ + getId: () => currentStage.id || "initial", getWriter: sinon.stub().returns({ byGlob: sinon.stub().resolves([]) }) }), useStage: sinon.stub().callsFake((stageName) => { - currentStage = stageName; + currentStage = {id: stageName}; }), setStage: sinon.stub().callsFake((stageName, stage) => { stages.set(stageName, stage); @@ -35,7 +36,7 @@ function createMockProject(name = "test.project", id = "test-project-id") { resultStageReader = reader; }), useResultStage: sinon.stub().callsFake(() => { - currentStage = "result"; + currentStage = {id: "result"}; }), _getCurrentStage: () => currentStage, _getResultStageReader: () => resultStageReader @@ -49,9 +50,10 @@ function createMockCacheManager() { writeIndexCache: sinon.stub().resolves(), readStageCache: sinon.stub().resolves(null), writeStageCache: sinon.stub().resolves(), - readBuildManifest: sinon.stub().resolves(null), - writeBuildManifest: sinon.stub().resolves(), - getResourcePathForStage: sinon.stub().resolves(null), + readResultMetadata: sinon.stub().resolves(null), + writeResultMetadata: sinon.stub().resolves(), + readTaskMetadata: sinon.stub().resolves(null), + writeTaskMetadata: sinon.stub().resolves(), writeStageResource: sinon.stub().resolves() }; } @@ -85,7 +87,6 @@ test("Create ProjectBuildCache instance", async (t) => { t.truthy(cache, "ProjectBuildCache instance created"); t.true(cacheManager.readIndexCache.called, "Index cache was attempted to be loaded"); - t.true(cacheManager.readBuildManifest.called, "Build manifest was attempted to be loaded"); }); test("Create with existing index cache", async (t) => { @@ -104,26 +105,56 @@ test("Create with existing index cache", async (t) => { version: 1, indexTimestamp: 1000, root: { - hash: "expected-hash", - children: {} + hash: "hash1", + children: { + "test.js": { + hash: "hash1", + metadata: { + path: "/test.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } } }, - taskMetadata: { - "task1": { + tasks: [["task1", false]] + }; + + // Mock task metadata responses + cacheManager.readTaskMetadata.callsFake((projectId, buildSig, taskName, type) => { + if (type === "project") { + return Promise.resolve({ requestSetGraph: { nodes: [], nextId: 1 - } - } + }, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false + }); + } else if (type === "dependencies") { + return Promise.resolve({ + requestSetGraph: { + nodes: [], + nextId: 1 + }, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false + }); } - }; + return Promise.resolve(null); + }); cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); t.truthy(cache, "Cache created with existing index"); - t.true(cache.hasTaskCache("task1"), "Task cache loaded from index"); + const taskCache = cache.getTaskCache("task1"); + t.truthy(taskCache, "Task cache loaded from index"); }); test("Initialize without any cache", async (t) => { @@ -133,36 +164,15 @@ test("Initialize without any cache", async (t) => { const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); - t.true(cache.requiresBuild(), "Build is required when no cache exists"); - t.false(cache.hasAnyCache(), "No task cache exists initially"); -}); - -test("requiresBuild returns true when invalidated tasks exist", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); - const buildSignature = "test-signature"; - - const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); - project.getSourceReader.returns({ - byGlob: sinon.stub().resolves([resource]) - }); - - const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); - - // Simulate having a task cache but with changed resources - cache.resourceChanged(["/test.js"], []); - - t.true(cache.requiresBuild(), "Build required when tasks invalidated"); + t.false(cache.isFresh(), "Cache is not fresh when empty"); }); -// ===== TASK CACHE TESTS ===== - -test("hasTaskCache returns false for non-existent task", async (t) => { +test("isFresh returns false for empty cache", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - t.false(cache.hasTaskCache("nonexistent"), "Task cache doesn't exist"); + t.false(cache.isFresh(), "Empty cache is not fresh"); }); test("getTaskCache returns undefined for non-existent task", async (t) => { @@ -173,13 +183,7 @@ test("getTaskCache returns undefined for non-existent task", async (t) => { t.is(cache.getTaskCache("nonexistent"), undefined, "Returns undefined"); }); -test("isTaskCacheValid returns false for non-existent task", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - - t.false(cache.isTaskCacheValid("nonexistent"), "Non-existent task is not valid"); -}); +// ===== TASK MANAGEMENT TESTS ===== test("setTasks initializes project stages", async (t) => { const project = createMockProject(); @@ -196,16 +200,14 @@ test("setTasks initializes project stages", async (t) => { ); }); -test("setDependencyReader sets the dependency reader", async (t) => { +test("setTasks with empty task list", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - const mockDependencyReader = {byGlob: sinon.stub()}; - cache.setDependencyReader(mockDependencyReader); + await cache.setTasks([]); - // The reader is stored internally, we can verify by checking it's used later - t.pass("Dependency reader set"); + t.true(project.initStages.calledWith([]), "initStages called with empty array"); }); test("allTasksCompleted switches to result stage", async (t) => { @@ -213,289 +215,368 @@ test("allTasksCompleted switches to result stage", async (t) => { const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - cache.allTasksCompleted(); + const changedPaths = await cache.allTasksCompleted(); t.true(project.useResultStage.calledOnce, "useResultStage called"); + t.true(Array.isArray(changedPaths), "Returns array of changed paths"); + t.true(cache.isFresh(), "Cache is fresh after all tasks completed"); }); -// ===== TASK EXECUTION TESTS ===== +test("allTasksCompleted returns changed resource paths", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + // Create cache with existing index to be able to track changes + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resource]) + })); + + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash1", + children: { + "test.js": { + hash: "hash1", + metadata: { + path: "/test.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [] + }; + cacheManager.readIndexCache.resolves(indexCache); + + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); -test("prepareTaskExecution: task needs execution when no cache exists", async (t) => { + // Simulate some changes - change tracking happens during prepareProjectBuildAndValidateCache + cache.projectSourcesChanged(["/test.js"]); + + const changedPaths = await cache.allTasksCompleted(); + + t.true(Array.isArray(changedPaths), "Returns array of changed paths"); +}); + +// ===== TASK EXECUTION AND RECORDING TESTS ===== + +test("prepareTaskExecutionAndValidateCache: task needs execution when no cache exists", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); await cache.setTasks(["myTask"]); - const needsExecution = await cache.prepareTaskExecution("myTask", false); + const canUseCache = await cache.prepareTaskExecutionAndValidateCache("myTask"); - t.true(needsExecution, "Task needs execution without cache"); + t.false(canUseCache, "Task cannot use cache"); t.true(project.useStage.calledWith("task/myTask"), "Project switched to task stage"); }); -test("prepareTaskExecution: switches project to correct stage", async (t) => { +test("prepareTaskExecutionAndValidateCache: switches project to correct stage", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); await cache.setTasks(["task1", "task2"]); - await cache.prepareTaskExecution("task2", false); + await cache.prepareTaskExecutionAndValidateCache("task2"); t.true(project.useStage.calledWith("task/task2"), "Switched to task2 stage"); }); -test("recordTaskResult: creates task cache if not exists", async (t) => { +test("recordTaskResult: creates task cache", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); await cache.setTasks(["newTask"]); - await cache.prepareTaskExecution("newTask", false); + await cache.prepareTaskExecutionAndValidateCache("newTask"); - const writtenPaths = new Set(["/output.js"]); const projectRequests = {paths: new Set(["/input.js"]), patterns: new Set()}; const dependencyRequests = {paths: new Set(), patterns: new Set()}; - await cache.recordTaskResult("newTask", writtenPaths, projectRequests, dependencyRequests); + await cache.recordTaskResult("newTask", projectRequests, dependencyRequests, null, false); - t.true(cache.hasTaskCache("newTask"), "Task cache created"); - t.true(cache.isTaskCacheValid("newTask"), "Task cache is valid"); + const taskCache = cache.getTaskCache("newTask"); + t.truthy(taskCache, "Task cache created"); }); -test("recordTaskResult: removes task from invalidated list", async (t) => { +test("recordTaskResult with empty requests", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); await cache.setTasks(["task1"]); - await cache.prepareTaskExecution("task1", false); + await cache.prepareTaskExecutionAndValidateCache("task1"); - // Record initial result - await cache.recordTaskResult("task1", new Set(), - {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); - - // Invalidate task - cache.resourceChanged(["/test.js"], []); + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; - // Re-execute and record - await cache.prepareTaskExecution("task1", false); - await cache.recordTaskResult("task1", new Set(), - {paths: new Set(), patterns: new Set()}, {paths: new Set(), patterns: new Set()}); + await cache.recordTaskResult("task1", projectRequests, dependencyRequests, null, false); - t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks after re-execution"); + const taskCache = cache.getTaskCache("task1"); + t.truthy(taskCache, "Task cache created even with no requests"); }); -// ===== RESOURCE CHANGE TESTS ===== +// ===== RESOURCE CHANGE TRACKING TESTS ===== -test("resourceChanged: invalidates no tasks when no cache exists", async (t) => { +test("projectSourcesChanged: marks cache as requiring validation", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - const taskInvalidated = cache.resourceChanged(["/test.js"], []); + // Create cache with existing index + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resource]) + })); - t.false(taskInvalidated, "No tasks invalidated when no cache exists"); - t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks"); -}); + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash1", + children: { + "test.js": { + hash: "hash1", + metadata: { + path: "/test.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [] + }; + cacheManager.readIndexCache.resolves(indexCache); -test("getChangedProjectResourcePaths: returns empty set for non-invalidated task", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - const changedPaths = cache.getChangedProjectResourcePaths("task1"); + cache.projectSourcesChanged(["/test.js"]); - t.deepEqual(changedPaths, new Set(), "Returns empty set"); + t.false(cache.isFresh(), "Cache is not fresh after changes"); }); -test("getChangedDependencyResourcePaths: returns empty set for non-invalidated task", async (t) => { +test("dependencyResourcesChanged: marks cache as requiring validation", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - const changedPaths = cache.getChangedDependencyResourcePaths("task1"); + // Create cache with existing index + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resource]) + })); - t.deepEqual(changedPaths, new Set(), "Returns empty set"); -}); + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash1", + children: { + "test.js": { + hash: "hash1", + metadata: { + path: "/test.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [] + }; + cacheManager.readIndexCache.resolves(indexCache); -test("resourceChanged: tracks changed resource paths", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - // Create a task cache first - await cache.setTasks(["task1"]); - await cache.prepareTaskExecution("task1", false); - await cache.recordTaskResult("task1", new Set(), - {paths: new Set(["/test.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}); - - // Now invalidate with changed resources - cache.resourceChanged(["/test.js", "/another.js"], ["/dep.js"]); - - const changedProject = cache.getChangedProjectResourcePaths("task1"); - const changedDeps = cache.getChangedDependencyResourcePaths("task1"); + cache.dependencyResourcesChanged(["/dep.js"]); - t.true(changedProject.has("/test.js"), "Project resource tracked"); - t.true(changedProject.has("/another.js"), "Another project resource tracked"); - t.true(changedDeps.has("/dep.js"), "Dependency resource tracked"); + t.false(cache.isFresh(), "Cache is not fresh after dependency changes"); }); -test("resourceChanged: accumulates multiple invalidations", async (t) => { +test("projectSourcesChanged: tracks multiple changes", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - // Create a task cache first - await cache.setTasks(["task1"]); - await cache.prepareTaskExecution("task1", false); - await cache.recordTaskResult("task1", new Set(), - {paths: new Set(["/test.js", "/another.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}); - - // First invalidation - cache.resourceChanged(["/test.js"], []); - - // Second invalidation - cache.resourceChanged(["/another.js"], []); + cache.projectSourcesChanged(["/test1.js"]); + cache.projectSourcesChanged(["/test2.js", "/test3.js"]); - const changedProject = cache.getChangedProjectResourcePaths("task1"); - - t.true(changedProject.has("/test.js"), "First change tracked"); - t.true(changedProject.has("/another.js"), "Second change tracked"); - t.is(changedProject.size, 2, "Both changes accumulated"); + // Changes are tracked internally + t.pass("Multiple changes tracked"); }); -// ===== INVALIDATION TESTS ===== - -test("getInvalidatedTaskNames: returns empty array when no tasks invalidated", async (t) => { +test("prepareProjectBuildAndValidateCache: returns false for empty cache", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - t.deepEqual(cache.getInvalidatedTaskNames(), [], "No invalidated tasks"); + const mockDependencyReader = { + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }; + + const result = await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + + t.is(result, false, "Returns false for empty cache"); }); -test("isTaskCacheValid: returns false for invalidated task", async (t) => { +test("refreshDependencyIndices: updates dependency indices", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - - // Create a task cache - await cache.setTasks(["task1"]); - await cache.prepareTaskExecution("task1", false); - await cache.recordTaskResult("task1", new Set(), - {paths: new Set(["/test.js"]), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}); - t.true(cache.isTaskCacheValid("task1"), "Task is valid initially"); + // Create cache with existing task + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resource]) + })); - // Invalidate it - cache.resourceChanged(["/test.js"], []); + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash1", + children: { + "test.js": { + hash: "hash1", + metadata: { + path: "/test.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [["task1", false]] + }; - t.false(cache.isTaskCacheValid("task1"), "Task is no longer valid after invalidation"); - t.deepEqual(cache.getInvalidatedTaskNames(), ["task1"], "Task appears in invalidated list"); -}); + // Mock task metadata responses + cacheManager.readTaskMetadata.callsFake((projectId, buildSig, taskName, type) => { + if (type === "project") { + return Promise.resolve({ + requestSetGraph: { + nodes: [], + nextId: 1 + }, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false + }); + } else if (type === "dependencies") { + return Promise.resolve({ + requestSetGraph: { + nodes: [], + nextId: 1 + }, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false + }); + } + return Promise.resolve(null); + }); -// ===== CACHE STORAGE TESTS ===== + cacheManager.readIndexCache.resolves(indexCache); -test("storeCache: writes index cache and build manifest", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - const buildManifest = { - manifestVersion: "1.0", - signature: "sig" - }; - - project.getReader.returns({ + const mockDependencyReader = { byGlob: sinon.stub().resolves([]), byPath: sinon.stub().resolves(null) - }); + }; - await cache.storeCache(buildManifest); + await cache.refreshDependencyIndices(mockDependencyReader); - t.true(cacheManager.writeBuildManifest.called, "Build manifest written"); - t.true(cacheManager.writeIndexCache.called, "Index cache written"); + t.pass("Dependency indices refreshed"); }); -test("storeCache: writes build manifest only once", async (t) => { +// ===== CACHE STORAGE TESTS ===== + +test("writeCache: writes index and stage caches", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - const buildManifest = { - manifestVersion: "1.0", - signature: "sig" - }; - project.getReader.returns({ byGlob: sinon.stub().resolves([]), byPath: sinon.stub().resolves(null) }); - await cache.storeCache(buildManifest); - await cache.storeCache(buildManifest); + await cache.writeCache(); - t.is(cacheManager.writeBuildManifest.callCount, 1, "Build manifest written only once"); + t.true(cacheManager.writeIndexCache.called, "Index cache written"); }); -// ===== BUILD MANIFEST TESTS ===== - -test("Load build manifest with correct version", async (t) => { +test("writeCache: skips writing unchanged caches", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); - cacheManager.readBuildManifest.resolves({ - buildManifest: { - manifestVersion: "1.0", - signature: "test-sig" - } - }); - - const cache = await ProjectBuildCache.create(project, "test-sig", cacheManager); + // Create cache with existing index + const resource = createMockResource("/test.js", "hash1", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resource]) + })); - t.truthy(cache, "Cache created successfully"); -}); + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash1", + children: { + "test.js": { + hash: "hash1", + metadata: { + path: "/test.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [] + }; + cacheManager.readIndexCache.resolves(indexCache); -test("Ignore build manifest with incompatible version", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - cacheManager.readBuildManifest.resolves({ - buildManifest: { - manifestVersion: "2.0", - signature: "test-sig" - } + project.getReader.returns({ + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) }); - const cache = await ProjectBuildCache.create(project, "test-sig", cacheManager); + // Write cache multiple times + await cache.writeCache(); + const firstCallCount = cacheManager.writeIndexCache.callCount; - t.truthy(cache, "Cache created despite incompatible manifest"); - t.true(cache.requiresBuild(), "Build required when manifest incompatible"); -}); - -test("Throw error on build signature mismatch", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); - - cacheManager.readBuildManifest.resolves({ - buildManifest: { - manifestVersion: "1.0", - signature: "wrong-signature" - } - }); + await cache.writeCache(); + const secondCallCount = cacheManager.writeIndexCache.callCount; - await t.throwsAsync( - async () => { - await ProjectBuildCache.create(project, "test-sig", cacheManager); - }, - { - message: /Build manifest signature wrong-signature does not match expected build signature test-sig/ - }, - "Throws error on signature mismatch" - ); + t.is(secondCallCount, firstCallCount + 1, "Index written each time"); }); + // ===== EDGE CASES ===== test("Create cache with empty project name", async (t) => { @@ -507,7 +588,7 @@ test("Create cache with empty project name", async (t) => { t.truthy(cache, "Cache created with empty project name"); }); -test("setTasks with empty task list", async (t) => { +test("Empty task list doesn't fail", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); @@ -516,48 +597,3 @@ test("setTasks with empty task list", async (t) => { t.true(project.initStages.calledWith([]), "initStages called with empty array"); }); - -test("prepareTaskExecution with requiresDependencies flag", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - - await cache.setTasks(["task1"]); - const needsExecution = await cache.prepareTaskExecution("task1", true); - - t.true(needsExecution, "Task needs execution"); - // Flag is passed but doesn't affect basic behavior without dependency reader -}); - -test("recordTaskResult with empty written paths", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - - await cache.setTasks(["task1"]); - await cache.prepareTaskExecution("task1", false); - - const writtenPaths = new Set(); - const projectRequests = {paths: new Set(), patterns: new Set()}; - const dependencyRequests = {paths: new Set(), patterns: new Set()}; - - await cache.recordTaskResult("task1", writtenPaths, projectRequests, dependencyRequests); - - t.true(cache.hasTaskCache("task1"), "Task cache created even with no written paths"); -}); - -test("hasAnyCache: returns true after recording task result", async (t) => { - const project = createMockProject(); - const cacheManager = createMockCacheManager(); - const cache = await ProjectBuildCache.create(project, "sig", cacheManager); - - t.false(cache.hasAnyCache(), "No cache initially"); - - await cache.setTasks(["task1"]); - await cache.prepareTaskExecution("task1", false); - await cache.recordTaskResult("task1", new Set(), - {paths: new Set(), patterns: new Set()}, - {paths: new Set(), patterns: new Set()}); - - t.true(cache.hasAnyCache(), "Has cache after recording result"); -}); From 443cb99fe050c1f80322aab0df1a7ce833a9625c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 16:42:56 +0100 Subject: [PATCH 129/223] test(project): Update ProjectBuilder tests and JSDoc --- packages/project/lib/build/ProjectBuilder.js | 82 ++++++- .../project/test/lib/build/ProjectBuilder.js | 207 +++++++----------- 2 files changed, 162 insertions(+), 127 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index b9dc279782e..75bee1539be 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -120,6 +120,14 @@ class ProjectBuilder { this.#log = new BuildLogger("ProjectBuilder"); } + /** + * Propagate resource changes through the build context + * + * @public + * @param {Array} changes Array of resource changes to propagate + * @returns {Promise} Promise resolving when changes have been propagated + * @throws {Error} If a build is currently running + */ resourcesChanged(changes) { if (this.#buildIsRunning) { throw new Error(`Unable to safely propagate resource changes. Build is currently running.`); @@ -127,6 +135,18 @@ class ProjectBuilder { return this._buildContext.propagateResourceChanges(changes); } + /** + * Build projects without writing to a target directory + * + * @public + * @param {object} parameters Parameters + * @param {boolean} [parameters.includeRootProject=true] Whether to include the root project + * @param {Array.} [parameters.includedDependencies=[]] List of dependencies to include + * @param {Array.} [parameters.excludedDependencies=[]] List of dependencies to exclude + * @param {AbortSignal} [parameters.signal] Signal to abort the build + * @param {Function} [projectBuiltCallback] Callback invoked after each project is built + * @returns {Promise} Promise resolving with array of processed project names + */ async build({ includeRootProject = true, includedDependencies = [], excludedDependencies = [], @@ -201,6 +221,17 @@ class ProjectBuilder { await Promise.all(pWrites); } + /** + * Determine which projects should be built based on filter criteria + * + * @param {boolean} includeRootProject Whether to include the root project + * @param {Array.} includedDependencies Dependencies to include + * @param {Array.} excludedDependencies Dependencies to exclude + * @param {@ui5/project/build/ProjectBuilder~DependencyIncludes} [dependencyIncludes] + * Alternative dependency configuration + * @returns {string[]} Array of project names to build + * @throws {Error} If creating a build manifest with multiple projects + */ _determineRequestedProjects(includeRootProject, includedDependencies, excludedDependencies, dependencyIncludes) { // Get project filter function based on include/exclude params // (also logs some info to console) @@ -228,6 +259,15 @@ class ProjectBuilder { return requestedProjects; } + /** + * Internal build implementation that orchestrates the actual build process + * + * @param {string[]} requestedProjects Array of project names to build + * @param {Function} [projectBuiltCallback] Callback invoked after each project is built + * @param {AbortSignal} [signal] Signal to abort the build + * @returns {Promise} Promise resolving with array of processed project names + * @throws {Error} If a build is already running + */ async #build(requestedProjects, projectBuiltCallback, signal) { if (this.#buildIsRunning) { throw new Error("A build is already running"); @@ -314,6 +354,13 @@ class ProjectBuilder { return processedProjectNames; } + /** + * Build a single project + * + * @param {object} projectBuildContext Build context for the project + * @param {AbortSignal} [signal] Signal to abort the build + * @returns {Promise} Promise resolving with array of changed resources + */ async _buildProject(projectBuildContext, signal) { const project = projectBuildContext.getProject(); const projectName = project.getName(); @@ -326,6 +373,17 @@ class ProjectBuilder { return changedResources; } + /** + * Create a filter function to determine which projects should be built + * + * @param {object} parameters Parameters + * @param {boolean} [parameters.includeRootProject=true] Whether to include the root project + * @param {@ui5/project/build/ProjectBuilder~DependencyIncludes} [parameters.dependencyIncludes] + * Dependency configuration + * @param {Array.} [parameters.explicitIncludes] Explicit dependencies to include + * @param {Array.} [parameters.explicitExcludes] Explicit dependencies to exclude + * @returns {Function} Filter function that takes a project name and returns boolean + */ _createProjectFilter({ includeRootProject = true, dependencyIncludes, @@ -375,6 +433,13 @@ class ProjectBuilder { }; } + /** + * Write build results for a project to the target destination + * + * @param {object} projectBuildContext Build context for the project + * @param {@ui5/fs/adapters/FileSystem} target Target adapter to write to + * @returns {Promise} Promise resolving when write is complete + */ async _writeResults(projectBuildContext, target) { const project = projectBuildContext.getProject(); const taskUtil = projectBuildContext.getTaskUtil(); @@ -458,12 +523,23 @@ class ProjectBuilder { } } + /** + * Execute cleanup tasks for all build contexts + * + * @param {boolean} [force] Whether to force cleanup execution + * @returns {Promise} Promise resolving when cleanup is complete + */ async _executeCleanupTasks(force) { this.#log.info("Executing cleanup tasks..."); await this._buildContext.executeCleanupTasks(force); } + /** + * Register signal handlers for cleanup on process termination + * + * @returns {object} Map of signal names to their handlers + */ _registerCleanupSigHooks() { const that = this; function createListener(exitCode) { @@ -505,6 +581,11 @@ class ProjectBuilder { return processSignals; } + /** + * Remove previously registered signal handlers + * + * @param {object} signals Map of signal names to their handlers + */ _deregisterCleanupSigHooks(signals) { for (const signal of Object.keys(signals)) { process.removeListener(signal, signals[signal]); @@ -514,7 +595,6 @@ class ProjectBuilder { /** * Calculates the elapsed build time and returns a prettified output * - * @private * @param {Array} startTime Array provided by process.hrtime() * @returns {string} Difference between now and the provided time array as formatted string */ diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index 548703e5e32..9401ab7646f 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -69,6 +69,13 @@ test.beforeEach(async (t) => { project: getMockProject("library", "c") }); }, + traverseDependenciesDepthFirst: sinon.stub().callsFake(function* (includeRoot) { + if (includeRoot) { + yield {project: getMockProject("application", "a")}; + } + yield {project: getMockProject("library", "b")}; + yield {project: getMockProject("library", "c")}; + }), getProject: sinon.stub().callsFake((projectName) => { return getMockProject(...projectName.split(".")); }) @@ -102,20 +109,22 @@ test("build", async (t) => { const builder = new ProjectBuilder({graph, taskRepository}); const filterProjectStub = sinon.stub().returns(true); - const getProjectFilterStub = sinon.stub(builder, "_getProjectFilter").resolves(filterProjectStub); + sinon.stub(builder, "_createProjectFilter").returns(filterProjectStub); const requiresBuildStub = sinon.stub().returns(true); - const runTasksStub = sinon.stub().resolves(); + const possiblyRequiresBuildStub = sinon.stub().returns(true); + const prepareProjectBuildAndValidateCacheStub = sinon.stub().resolves(false); + const buildProjectStub = sinon.stub().resolves(); + const writeBuildCacheStub = sinon.stub().resolves(); const projectBuildContextMock = { - getTaskRunner: () => { - return { - runTasks: runTasksStub, - }; - }, + possiblyRequiresBuild: possiblyRequiresBuildStub, + prepareProjectBuildAndValidateCache: prepareProjectBuildAndValidateCacheStub, + buildProject: buildProjectStub, + writeBuildCache: writeBuildCacheStub, requiresBuild: requiresBuildStub, getProject: sinon.stub().returns(getMockProject("library")) }; - const createRequiredBuildContextsStub = sinon.stub(builder, "_createRequiredBuildContexts") + const getRequiredProjectContextsStub = sinon.stub(builder._buildContext, "getRequiredProjectContexts") .resolves(new Map().set("project.a", projectBuildContextMock)); const registerCleanupSigHooksStub = sinon.stub(builder, "_registerCleanupSigHooks").returns("cleanup sig hooks"); @@ -124,28 +133,21 @@ test("build", async (t) => { const deregisterCleanupSigHooksStub = sinon.stub(builder, "_deregisterCleanupSigHooks"); const executeCleanupTasksStub = sinon.stub(builder, "_executeCleanupTasks").resolves(); - await builder.build({ + await builder.buildToTarget({ destPath: "dest/path", includedDependencies: ["dep a"], excludedDependencies: ["dep b"] }); - t.is(getProjectFilterStub.callCount, 1, "_getProjectFilter got called once"); - t.deepEqual(getProjectFilterStub.getCall(0).args[0], { - explicitIncludes: ["dep a"], - explicitExcludes: ["dep b"], - dependencyIncludes: undefined - }, "_getProjectFilter got called with correct arguments"); - - t.is(createRequiredBuildContextsStub.callCount, 1, "_createRequiredBuildContexts got called once"); - t.deepEqual(createRequiredBuildContextsStub.getCall(0).args[0], [ + t.is(getRequiredProjectContextsStub.callCount, 1, "getRequiredProjectContexts got called once"); + t.deepEqual(getRequiredProjectContextsStub.getCall(0).args[0], [ "project.a", "project.b", "project.c" - ], "_createRequiredBuildContexts got called with correct arguments"); + ], "getRequiredProjectContexts got called with correct arguments"); - t.is(requiresBuildStub.callCount, 1, "ProjectBuildContext#requiresBuild got called once"); + t.is(possiblyRequiresBuildStub.callCount, 1, "ProjectBuildContext#possiblyRequiresBuild got called once"); t.is(registerCleanupSigHooksStub.callCount, 1, "_registerCleanupSigHooksStub got called once"); - t.is(runTasksStub.callCount, 1, "TaskRunner#runTasks got called once"); + t.is(buildProjectStub.callCount, 1, "ProjectBuildContext#buildProject got called once"); t.is(writeResultsStub.callCount, 1, "_writeResults got called once"); t.is(writeResultsStub.getCall(0).args[0], projectBuildContextMock, @@ -153,18 +155,20 @@ test("build", async (t) => { t.is(writeResultsStub.getCall(0).args[1]._fsBasePath, path.resolve("dest/path") + path.sep, "_writeResults got called with correct second argument"); + t.is(writeBuildCacheStub.callCount, 1, "writeBuildCache got called once"); + t.is(deregisterCleanupSigHooksStub.callCount, 1, "_deregisterCleanupSigHooks got called once"); t.is(deregisterCleanupSigHooksStub.getCall(0).args[0], "cleanup sig hooks", "_deregisterCleanupSigHooks got called with correct arguments"); t.is(executeCleanupTasksStub.callCount, 1, "_executeCleanupTasksStub got called once"); }); -test("build: Missing dest parameter", async (t) => { +test("build: Conflicting dependency parameters", async (t) => { const {graph, taskRepository, ProjectBuilder} = t.context; const builder = new ProjectBuilder({graph, taskRepository}); - const err = await t.throwsAsync(builder.build({ + const err = await t.throwsAsync(builder.buildToTarget({ destPath: "dest/path", dependencyIncludes: "dependencyIncludes", includedDependencies: ["dep a"], @@ -182,7 +186,7 @@ test("build: Too many dependency parameters", async (t) => { const builder = new ProjectBuilder({graph, taskRepository}); - const err = await t.throwsAsync(builder.build({ + const err = await t.throwsAsync(builder.buildToTarget({ includedDependencies: ["dep a"], excludedDependencies: ["dep b"] })); @@ -200,8 +204,8 @@ test("build: createBuildManifest in conjunction with dependencies", async (t) => }); const filterProjectStub = sinon.stub().returns(true); - sinon.stub(builder, "_getProjectFilter").resolves(filterProjectStub); - const err = await t.throwsAsync(builder.build({ + sinon.stub(builder, "_createProjectFilter").returns(filterProjectStub); + const err = await t.throwsAsync(builder.buildToTarget({ destPath: "dest/path", includedDependencies: ["dep a"] })); @@ -218,20 +222,18 @@ test("build: Failure", async (t) => { const builder = new ProjectBuilder({graph, taskRepository}); const filterProjectStub = sinon.stub().returns(true); - sinon.stub(builder, "_getProjectFilter").resolves(filterProjectStub); + sinon.stub(builder, "_createProjectFilter").returns(filterProjectStub); - const requiresBuildStub = sinon.stub().returns(true); - const runTasksStub = sinon.stub().rejects(new Error("Some Error")); + const possiblyRequiresBuildStub = sinon.stub().returns(true); + const prepareProjectBuildAndValidateCacheStub = sinon.stub().resolves(false); + const buildProjectStub = sinon.stub().rejects(new Error("Some Error")); const projectBuildContextMock = { - requiresBuild: requiresBuildStub, - getTaskRunner: () => { - return { - runTasks: runTasksStub - }; - }, + possiblyRequiresBuild: possiblyRequiresBuildStub, + prepareProjectBuildAndValidateCache: prepareProjectBuildAndValidateCacheStub, + buildProject: buildProjectStub, getProject: sinon.stub().returns(getMockProject("library")) }; - sinon.stub(builder, "_createRequiredBuildContexts") + sinon.stub(builder._buildContext, "getRequiredProjectContexts") .resolves(new Map().set("project.a", projectBuildContextMock)); sinon.stub(builder, "_registerCleanupSigHooks").returns("cleanup sig hooks"); @@ -239,7 +241,7 @@ test("build: Failure", async (t) => { const deregisterCleanupSigHooksStub = sinon.stub(builder, "_deregisterCleanupSigHooks"); const executeCleanupTasksStub = sinon.stub(builder, "_executeCleanupTasks").resolves(); - const err = await t.throwsAsync(builder.build({ + const err = await t.throwsAsync(builder.buildToTarget({ destPath: "dest/path", includedDependencies: ["dep a"], excludedDependencies: ["dep b"] @@ -281,45 +283,35 @@ test.serial("build: Multiple projects", async (t) => { const builder = new ProjectBuilder({graph, taskRepository}); const filterProjectStub = sinon.stub().returns(true).onFirstCall().returns(false); - const getProjectFilterStub = sinon.stub(builder, "_getProjectFilter").resolves(filterProjectStub); - - const requiresBuildAStub = sinon.stub().returns(true); - const requiresBuildBStub = sinon.stub().returns(false); - const requiresBuildCStub = sinon.stub().returns(true); - const getBuildMetadataStub = sinon.stub().returns({ - timestamp: "2022-07-28T12:00:00.000Z", - age: "xx days" - }); - const runTasksStub = sinon.stub().resolves(); + sinon.stub(builder, "_createProjectFilter").returns(filterProjectStub); + + const buildProjectAStub = sinon.stub().resolves(); + const buildProjectBStub = sinon.stub().resolves(); + const buildProjectCStub = sinon.stub().resolves(); + const writeBuildCacheStub = sinon.stub().resolves(); + const projectBuildContextMockA = { - getTaskRunner: () => { - return { - runTasks: runTasksStub - }; - }, - requiresBuild: requiresBuildAStub, + possiblyRequiresBuild: sinon.stub().returns(true), + prepareProjectBuildAndValidateCache: sinon.stub().resolves(false), + buildProject: buildProjectAStub, + writeBuildCache: writeBuildCacheStub, getProject: sinon.stub().returns(getMockProject("library", "a")) }; const projectBuildContextMockB = { - getTaskRunner: () => { - return { - runTasks: runTasksStub - }; - }, - getBuildMetadata: getBuildMetadataStub, - requiresBuild: requiresBuildBStub, + possiblyRequiresBuild: sinon.stub().returns(false), + prepareProjectBuildAndValidateCache: sinon.stub().resolves(false), + buildProject: buildProjectBStub, + writeBuildCache: writeBuildCacheStub, getProject: sinon.stub().returns(getMockProject("library", "b")) }; const projectBuildContextMockC = { - getTaskRunner: () => { - return { - runTasks: runTasksStub - }; - }, - requiresBuild: requiresBuildCStub, + possiblyRequiresBuild: sinon.stub().returns(true), + prepareProjectBuildAndValidateCache: sinon.stub().resolves(false), + buildProject: buildProjectCStub, + writeBuildCache: writeBuildCacheStub, getProject: sinon.stub().returns(getMockProject("library", "c")) }; - const createRequiredBuildContextsStub = sinon.stub(builder, "_createRequiredBuildContexts") + const getRequiredProjectContextsStub = sinon.stub(builder._buildContext, "getRequiredProjectContexts") .resolves(new Map() .set("project.a", projectBuildContextMockA) .set("project.b", projectBuildContextMockB) @@ -332,30 +324,22 @@ test.serial("build: Multiple projects", async (t) => { const executeCleanupTasksStub = sinon.stub(builder, "_executeCleanupTasks").resolves(); setLogLevel("verbose"); - await builder.build({ + await builder.buildToTarget({ destPath: path.join("dest", "path"), dependencyIncludes: "dependencyIncludes" }); setLogLevel("info"); - t.is(getProjectFilterStub.callCount, 1, "_getProjectFilter got called once"); - t.deepEqual(getProjectFilterStub.getCall(0).args[0], { - explicitIncludes: [], - explicitExcludes: [], - dependencyIncludes: "dependencyIncludes" - }, "_getProjectFilter got called with correct arguments"); - - t.is(createRequiredBuildContextsStub.callCount, 1, "_createRequiredBuildContexts got called once"); - t.deepEqual(createRequiredBuildContextsStub.getCall(0).args[0], [ + t.is(getRequiredProjectContextsStub.callCount, 1, "getRequiredProjectContexts got called once"); + t.deepEqual(getRequiredProjectContextsStub.getCall(0).args[0], [ "project.b", "project.c" - ], "_createRequiredBuildContexts got called with correct arguments"); + ], "getRequiredProjectContexts got called with correct arguments"); - t.is(requiresBuildAStub.callCount, 1, "TaskRunner#requiresBuild got called once times for library.a"); - t.is(requiresBuildBStub.callCount, 1, "TaskRunner#requiresBuild got called once times for library.b"); - t.is(requiresBuildCStub.callCount, 1, "TaskRunner#requiresBuild got called once times for library.c"); t.is(registerCleanupSigHooksStub.callCount, 1, "_registerCleanupSigHooksStub got called once"); - t.is(runTasksStub.callCount, 2, "TaskRunner#runTasks got called twice"); // library.b does not require a build + t.is(buildProjectAStub.callCount, 1, "buildProject got called once for library.a"); + t.is(buildProjectBStub.callCount, 0, "buildProject not called for library.b (possiblyRequiresBuild = false)"); + t.is(buildProjectCStub.callCount, 1, "buildProject got called once for library.c"); t.is(writeResultsStub.callCount, 2, "_writeResults got called twice"); // library.a has not been requested t.is(writeResultsStub.getCall(0).args[0], projectBuildContextMockB, @@ -396,47 +380,10 @@ test.serial("build: Multiple projects", async (t) => { "BuildLogger#skipProjectBuild got called with expected argument"); }); -test("_createRequiredBuildContexts", async (t) => { - const {graph, taskRepository, ProjectBuilder, sinon} = t.context; - - const builder = new ProjectBuilder({graph, taskRepository}); - - const requiresBuildStub = sinon.stub().returns(true); - const getRequiredDependenciesStub = sinon.stub() - .returns(new Set()) - .onFirstCall().returns(new Set(["project.b"])); // required dependency of project.a - - const projectBuildContextMock = { - requiresBuild: requiresBuildStub, - getTaskRunner: () => { - return { - getRequiredDependencies: getRequiredDependenciesStub - }; - } - }; - const createProjectContextStub = sinon.stub(builder._buildContext, "createProjectContext") - .returns(projectBuildContextMock); - const projectBuildContexts = await builder._createRequiredBuildContexts(["project.a", "project.c"]); - - t.is(requiresBuildStub.callCount, 3, "TaskRunner#requiresBuild got called three times"); - t.is(getRequiredDependenciesStub.callCount, 3, "TaskRunner#getRequiredDependencies got called three times"); - - t.deepEqual(Object.fromEntries(projectBuildContexts), { - "project.a": projectBuildContextMock, - "project.b": projectBuildContextMock, // is a required dependency of project.a - "project.c": projectBuildContextMock, - }, "Returned expected project build contexts"); - - t.is(createProjectContextStub.callCount, 3, "BuildContext#createProjectContextStub got called three times"); - t.is(createProjectContextStub.getCall(0).args[0].project.getName(), "project.a", - "First call to BuildContext#createProjectContextStub with expected project"); - t.is(createProjectContextStub.getCall(1).args[0].project.getName(), "project.c", - "Second call to BuildContext#createProjectContextStub with expected project"); - t.is(createProjectContextStub.getCall(2).args[0].project.getName(), "project.b", - "Third call to BuildContext#createProjectContextStub with expected project"); -}); +// _createRequiredBuildContexts is now part of BuildContext, not ProjectBuilder +// This logic is tested through integration tests -test.serial("_getProjectFilter with dependencyIncludes", async (t) => { +test.serial("_createProjectFilter with dependencyIncludes", async (t) => { const {graph, taskRepository, sinon} = t.context; const composeProjectListStub = sinon.stub().returns({ includedDependencies: ["project.b", "project.c"], @@ -448,7 +395,7 @@ test.serial("_getProjectFilter with dependencyIncludes", async (t) => { const builder = new ProjectBuilder({graph, taskRepository}); - const filterProject = await builder._getProjectFilter({ + const filterProject = builder._createProjectFilter({ dependencyIncludes: "dependencyIncludes", explicitIncludes: "explicitIncludes", explicitExcludes: "explicitExcludes", @@ -467,7 +414,7 @@ test.serial("_getProjectFilter with dependencyIncludes", async (t) => { t.false(filterProject("project.e"), "project.e is not allowed"); }); -test.serial("_getProjectFilter with explicit include/exclude", async (t) => { +test.serial("_createProjectFilter with explicit include/exclude", async (t) => { const {graph, taskRepository, sinon} = t.context; const composeProjectListStub = sinon.stub().returns({ includedDependencies: ["project.b", "project.c"], @@ -479,7 +426,7 @@ test.serial("_getProjectFilter with explicit include/exclude", async (t) => { const builder = new ProjectBuilder({graph, taskRepository}); - const filterProject = await builder._getProjectFilter({ + const filterProject = builder._createProjectFilter({ explicitIncludes: "explicitIncludes", explicitExcludes: "explicitExcludes", }); @@ -610,6 +557,7 @@ test.serial("_writeResults: Create build manifest", async (t) => { const getTagStub = sinon.stub().returns(false).onFirstCall().returns(true); const projectBuildContextMock = { getProject: () => mockProject, + getBuildSignature: () => "build-signature", getTaskUtil: () => { return { isRootProject: () => true, @@ -637,7 +585,9 @@ test.serial("_writeResults: Create build manifest", async (t) => { t.is(createBuildManifestStub.callCount, 1, "createBuildManifest got called once"); t.is(createBuildManifestStub.getCall(0).args[0], mockProject, "createBuildManifest got called with correct project"); - t.deepEqual(createBuildManifestStub.getCall(0).args[1], { + t.is(createBuildManifestStub.getCall(0).args[1], graph, + "createBuildManifest got called with correct graph"); + t.deepEqual(createBuildManifestStub.getCall(0).args[2], { createBuildManifest: true, outputStyle: OutputStyleEnum.Default, cssVariables: false, @@ -645,7 +595,12 @@ test.serial("_writeResults: Create build manifest", async (t) => { includedTasks: [], jsdoc: false, selfContained: false, + useCache: false, }, "createBuildManifest got called with correct build configuration"); + t.is(createBuildManifestStub.getCall(0).args[3], taskRepository, + "createBuildManifest got called with correct taskRepository"); + t.is(createBuildManifestStub.getCall(0).args[4], "build-signature", + "createBuildManifest got called with correct buildSignature"); t.is(createResourceStub.callCount, 1, "One resource has been created"); t.deepEqual(createResourceStub.getCall(0).args[0], { From fac2209e850ab4fb1ea52f00d4be3c7de9562c16 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 17:18:15 +0100 Subject: [PATCH 130/223] test(project): Update TaskRunner tests --- packages/project/lib/build/TaskRunner.js | 135 +++- packages/project/test/lib/build/TaskRunner.js | 581 +++++++++--------- 2 files changed, 414 insertions(+), 302 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 0b1815552e7..e913892e3e4 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -5,16 +5,18 @@ import {createReaderCollection, createMonitor} from "@ui5/fs/resourceFactory"; /** * TaskRunner * - * @private + * Manages the execution of build tasks for a project, including task composition, + * dependency management, and custom task integration. + * * @hideconstructor */ class TaskRunner { /** * Constructor * - * @param {object} parameters - * @param {object} parameters.graph - * @param {object} parameters.project + * @param {object} parameters Parameters + * @param {@ui5/project/graph/ProjectGraph} parameters.graph Project graph instance + * @param {@ui5/project/specifications/Project} parameters.project Project instance * @param {@ui5/logger/loggers/ProjectBuild} parameters.log Logger to use * @param {@ui5/project/build/cache/ProjectBuildCache} parameters.buildCache Build cache instance * @param {@ui5/project/build/helpers/TaskUtil} parameters.taskUtil TaskUtil instance @@ -37,6 +39,16 @@ class TaskRunner { this._directDependencies = new Set(this._taskUtil.getDependencies()); } + /** + * Initializes the task list based on the project type + * + * This method: + * 1. Loads the appropriate build definition for the project type + * 2. Adds all standard tasks from the definition + * 3. Adds any custom tasks configured for the project + * + * @returns {Promise} + */ async _initTasks() { if (this._tasks) { return; @@ -84,10 +96,18 @@ class TaskRunner { } /** - * Takes a list of tasks which should be executed from the available task list of the current builder + * Executes all configured tasks for the project * - * @param {AbortSignal} [signal] Abort signal - * @returns {Promise} Resolves with list of changed resources since the last build + * This method: + * 1. Initializes the task list if not already done + * 2. Ensures dependency reader is ready + * 3. Composes the final list of tasks to execute based on build configuration + * 4. Executes each task in order, respecting cache and abort signals + * 5. Returns the list of changed resources after all tasks complete + * + * @public + * @param {AbortSignal} [signal] Abort signal to cancel task execution + * @returns {Promise} Array of changed resource paths since the last build */ async runTasks(signal) { await this._initTasks(); @@ -125,10 +145,15 @@ class TaskRunner { } /** - * First compiles a list of all tasks that will be executed, then a list of all direct project - * dependencies that those tasks require access to. + * Determines which project dependencies are required by the tasks that will be executed + * + * This method: + * 1. Initializes the task list if needed + * 2. Composes the list of tasks that will be executed + * 3. Collects all dependencies required by those tasks * - * @returns {Set} Returns a set containing the names of all required direct project dependencies + * @public + * @returns {Promise>} Set containing the names of all required direct project dependencies */ async getRequiredDependencies() { if (this._requiredDependencies) { @@ -163,14 +188,19 @@ class TaskRunner { /** * Adds an executable task to the builder * - * The order this function is being called defines the build order. FIFO. + * The order this function is called defines the build order (FIFO). + * Tasks can be explicitly skipped by setting taskFunction to null. * - * @param {string} taskName Name of the task which should be in the list availableTasks. - * @param {object} [parameters] - * @param {boolean} [parameters.requiresDependencies] - * @param {boolean} [parameters.supportsDifferentialUpdates] - * @param {object} [parameters.options] - * @param {Function} [parameters.taskFunction] + * @param {string} taskName Name of the task to add + * @param {object} [parameters] Task parameters + * @param {boolean} [parameters.requiresDependencies=false] + * Whether the task requires access to project dependencies + * @param {boolean} [parameters.supportsDifferentialUpdates=false] + * Whether the task supports differential updates using cache + * @param {object} [parameters.options={}] Options to pass to the task + * @param {Function|null} [parameters.taskFunction] + * Task function to execute, or null to explicitly skip the task + * @returns {void} */ _addTask(taskName, { requiresDependencies = false, supportsDifferentialUpdates = false, options = {}, taskFunction @@ -243,8 +273,11 @@ class TaskRunner { } /** + * Adds all custom tasks configured for the project + * + * Processes custom tasks in the order they are defined in the project configuration. * - * @private + * @returns {Promise} */ async _addCustomTasks() { const projectCustomTasks = this._project.getCustomTasks(); @@ -257,10 +290,21 @@ class TaskRunner { } } /** - * Adds custom tasks to execute + * Adds a single custom task to the task execution order * - * @private - * @param {object} taskDef + * This method: + * 1. Validates the custom task definition + * 2. Loads the task extension from the project graph + * 3. Determines required dependencies via callback if provided + * 4. Creates a wrapper function for the custom task + * 5. Inserts the task at the correct position based on beforeTask/afterTask configuration + * + * @param {object} taskDef Custom task definition from project configuration + * @param {string} taskDef.name Name of the custom task + * @param {string} [taskDef.beforeTask] Name of task to insert before + * @param {string} [taskDef.afterTask] Name of task to insert after + * @param {object} [taskDef.configuration] Custom task configuration + * @returns {Promise} */ async _addCustomTask(taskDef) { const project = this._project; @@ -418,6 +462,30 @@ class TaskRunner { } } + /** + * Creates a wrapper function for executing a custom task + * + * The wrapper: + * 1. Validates cache and determines if task can be skipped + * 2. Prepares workspace and dependencies readers + * 3. Builds the parameter object for the custom task interface + * 4. Executes the custom task function + * 5. Records the task result in the build cache + * + * @param {object} parameters Parameters + * @param {@ui5/project/specifications/Project} parameters.project Project instance + * @param {@ui5/project/build/helpers/TaskUtil} parameters.taskUtil TaskUtil instance + * @param {Function} parameters.getDependenciesReaderCb + * Callback to get dependencies reader on-demand + * @param {boolean} parameters.provideDependenciesReader + * Whether to provide dependencies reader to the task + * @param {boolean} parameters.supportsDifferentialUpdates + * Whether the task supports differential updates + * @param {@ui5/project/specifications/Extension} parameters.task Task extension instance + * @param {string} parameters.taskName Runtime name of the task (may include suffix) + * @param {object} [parameters.taskConfiguration] Task configuration from ui5.yaml + * @returns {Function} Async wrapper function for the custom task + */ _createCustomTaskWrapper({ project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, supportsDifferentialUpdates, task, taskName, taskConfiguration @@ -497,13 +565,14 @@ class TaskRunner { } /** - * Adds progress related functionality to task function. + * Executes a task function with performance tracking + * + * Wraps task execution with performance measurements and logging. * - * @private * @param {string} taskName Name of the task - * @param {Function} taskFunction Function which executed the task + * @param {Function} taskFunction Function which executes the task * @param {object} taskParams Base parameters for all tasks - * @returns {Promise} Resolves when task has finished + * @returns {Promise} Resolves when task has finished */ async _executeTask(taskName, taskFunction, taskParams) { this._taskStart = performance.now(); @@ -515,8 +584,22 @@ class TaskRunner { } } + /** + * Creates a reader collection for the specified project dependencies + * + * This method: + * 1. Returns a cached reader if all direct dependencies are requested and available + * 2. Resolves transitive dependencies for the requested dependency names + * 3. Creates a reader collection containing readers for all required dependencies + * 4. Caches the reader if it covers all direct dependencies + * + * @public + * @param {Set} dependencyNames Set of dependency project names to include + * @param {boolean} [forceUpdate=false] Force creation of a new reader even if cached + * @returns {Promise<@ui5/fs/ReaderCollection>} Reader collection for the requested dependencies + */ async getDependenciesReader(dependencyNames, forceUpdate = false) { - if (!forceUpdate && dependencyNames.size === this._directDependencies.size) { + if (!forceUpdate && dependencyNames.size === this._directDependencies.size && this._cachedDependenciesReader) { // Shortcut: If all direct dependencies are required, just return the already created reader return this._cachedDependenciesReader; } diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index 0db7a9514b5..4ef8fc11afe 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -57,7 +57,11 @@ function getMockProject(type) { getCachebusterSignatureType: noop, getCustomTasks: () => [], hasBuildManifest: () => false, - getWorkspace: () => "workspace", + getWorkspace: () => { + return { + getName: () => "workspace" + }; + }, isFrameworkProject: () => false, sealWorkspace: noop, createNewWorkspaceVersion: noop, @@ -94,6 +98,7 @@ test.beforeEach(async (t) => { }; }, getRequiredDependenciesCallback: t.context.getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false), }; t.context.graph = { @@ -115,18 +120,34 @@ test.beforeEach(async (t) => { setTasks: sinon.stub(), startTask: sinon.stub(), endTask: sinon.stub(), + skipTask: sinon.stub(), verbose: sinon.stub(), perf: sinon.stub(), isLevelEnabled: sinon.stub().returns(true), }; - t.context.cache = { + t.context.buildCache = { setTasks: sinon.stub(), + prepareTaskExecutionAndValidateCache: sinon.stub().resolves(false), + recordTaskResult: sinon.stub().resolves(), + allTasksCompleted: sinon.stub().resolves([]), }; t.context.resourceFactory = { createReaderCollection: sinon.stub() - .returns("reader collection") + .returns({getName: () => "reader collection"}), + createMonitor: sinon.stub().callsFake((resource) => { + // Return a MonitoredReader-like object with both getName and getResourceRequests + if (resource && typeof resource.getName === "function") { + const name = resource.getName(); + return { + constructor: {name: "MonitoredReader"}, + getName: () => name, + getResourceRequests: sinon.stub().returns([]) + }; + } + return resource; + }) }; t.context.TaskRunner = await esmock("../../../lib/build/TaskRunner.js", { @@ -140,7 +161,7 @@ test.afterEach.always((t) => { }); test("Missing parameters", (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; t.throws(() => { new TaskRunner({ graph, @@ -158,7 +179,7 @@ test("Missing parameters", (t) => { taskUtil, taskRepository, log: projectBuildLogger, - cache, + buildCache, buildConfig }); }, { @@ -170,7 +191,7 @@ test("Missing parameters", (t) => { graph, taskRepository, log: projectBuildLogger, - cache, + buildCache, buildConfig }); }, { @@ -182,7 +203,7 @@ test("Missing parameters", (t) => { graph, taskUtil, log: projectBuildLogger, - cache, + buildCache, buildConfig }); }, { @@ -206,7 +227,7 @@ test("Missing parameters", (t) => { taskUtil, taskRepository, log: projectBuildLogger, - cache, + buildCache, }); }, { message: "TaskRunner: One or more mandatory parameters not provided" @@ -214,9 +235,10 @@ test("Missing parameters", (t) => { }); test("_initTasks: Project of type 'application'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("application"), graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project: getMockProject("application"), graph, taskUtil, taskRepository, + log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -238,9 +260,10 @@ test("_initTasks: Project of type 'application'", async (t) => { }); test("_initTasks: Project of type 'library'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("library"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project: getMockProject("library"), graph, taskUtil, taskRepository, + log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -264,13 +287,13 @@ test("_initTasks: Project of type 'library'", async (t) => { }); test("_initTasks: Project of type 'library' (framework project)", async (t) => { - const {graph, taskUtil, taskRepository, projectBuildLogger, TaskRunner, cache} = t.context; + const {graph, taskUtil, taskRepository, projectBuildLogger, TaskRunner, buildCache} = t.context; const project = getMockProject("library"); project.isFrameworkProject = () => true; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -294,10 +317,10 @@ test("_initTasks: Project of type 'library' (framework project)", async (t) => { }); test("_initTasks: Project of type 'theme-library'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskRunner = new TaskRunner({ project: getMockProject("theme-library"), graph, taskUtil, taskRepository, - log: projectBuildLogger, cache, buildConfig + log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -311,13 +334,13 @@ test("_initTasks: Project of type 'theme-library'", async (t) => { }); test("_initTasks: Project of type 'theme-library' (framework project)", async (t) => { - const {graph, taskUtil, taskRepository, projectBuildLogger, cache, TaskRunner} = t.context; + const {graph, taskUtil, taskRepository, projectBuildLogger, buildCache, TaskRunner} = t.context; const project = getMockProject("theme-library"); project.isFrameworkProject = () => true; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -331,9 +354,10 @@ test("_initTasks: Project of type 'theme-library' (framework project)", async (t }); test("_initTasks: Project of type 'module'", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("module"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project: getMockProject("module"), graph, taskUtil, taskRepository, + log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -341,9 +365,10 @@ test("_initTasks: Project of type 'module'", async (t) => { }); test("_initTasks: Unknown project type", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskRunner = new TaskRunner({ - project: getMockProject("pony"), graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project: getMockProject("pony"), graph, taskUtil, taskRepository, + log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(taskRunner._initTasks()); @@ -351,14 +376,14 @@ test("_initTasks: Unknown project type", async (t) => { }); test("_initTasks: Custom tasks", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "minify"}, {name: "myOtherTask", beforeTask: "replaceVersion"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -382,14 +407,14 @@ test("_initTasks: Custom tasks", async (t) => { }); test("_initTasks: Custom tasks with no standard tasks", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); project.getCustomTasks = () => [ {name: "myTask"}, {name: "myOtherTask", beforeTask: "myTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -399,14 +424,14 @@ test("_initTasks: Custom tasks with no standard tasks", async (t) => { }); test("_initTasks: Custom tasks with no standard tasks and second task defining no before-/afterTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); project.getCustomTasks = () => [ {name: "myTask"}, {name: "myOtherTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -418,13 +443,13 @@ test("_initTasks: Custom tasks with no standard tasks and second task defining n }); test("_initTasks: Custom tasks with both, before- and afterTask reference", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", beforeTask: "minify", afterTask: "replaceVersion"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -436,13 +461,13 @@ test("_initTasks: Custom tasks with both, before- and afterTask reference", asyn }); test("_initTasks: Custom tasks with no before-/afterTask reference", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -454,13 +479,13 @@ test("_initTasks: Custom tasks with no before-/afterTask reference", async (t) = }); test("_initTasks: Custom tasks without name", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: ""} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -471,13 +496,13 @@ test("_initTasks: Custom tasks without name", async (t) => { }); test("_initTasks: Custom task with name of standard tasks", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "replaceVersion", afterTask: "minify"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -489,7 +514,7 @@ test("_initTasks: Custom task with name of standard tasks", async (t) => { }); test("_initTasks: Multiple custom tasks with same name", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "minify"}, @@ -497,7 +522,7 @@ test("_initTasks: Multiple custom tasks with same name", async (t) => { {name: "myTask", afterTask: "minify"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -522,13 +547,13 @@ test("_initTasks: Multiple custom tasks with same name", async (t) => { }); test("_initTasks: Custom tasks with unknown beforeTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", beforeTask: "unknownTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -540,13 +565,13 @@ test("_initTasks: Custom tasks with unknown beforeTask", async (t) => { }); test("_initTasks: Custom tasks with unknown afterTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "unknownTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -558,14 +583,14 @@ test("_initTasks: Custom tasks with unknown afterTask", async (t) => { }); test("_initTasks: Custom tasks is unknown", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; graph.getExtension.returns(undefined); const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", afterTask: "minify"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -577,13 +602,13 @@ test("_initTasks: Custom tasks is unknown", async (t) => { }); test("_initTasks: Custom tasks with removed beforeTask", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getCustomTasks = () => [ {name: "myTask", beforeTask: "removedTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); const err = await t.throwsAsync(async () => { await taskRunner._initTasks(); @@ -596,12 +621,16 @@ test("_initTasks: Custom tasks with removed beforeTask", async (t) => { }); test("_initTasks: Create dependencies reader for all dependencies", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); + // Dependencies reader is now created lazily via getDependenciesReader + // Use forceUpdate=true to bypass the cache shortcut and actually trigger graph traversal + const readerPromise = taskRunner.getDependenciesReader(new Set(["dep.a", "dep.b"]), true); + // Verify traverseBreadthFirst was called t.is(graph.traverseBreadthFirst.callCount, 1, "ProjectGraph#traverseBreadthFirst called once"); t.is(graph.traverseBreadthFirst.getCall(0).args[0], "project.b", "ProjectGraph#traverseBreadthFirst called with correct project name for start"); @@ -628,21 +657,23 @@ test("_initTasks: Create dependencies reader for all dependencies", async (t) => }); await traversalCallback({ project: { - getName: () => "transitive.dep.a", - getReader: () => "transitive.dep.a reader", + getName: () => "dep.c", + getReader: () => "dep.c reader", } }); + // Now wait for the reader to be created + await readerPromise; t.is(resourceFactory.createReaderCollection.callCount, 1, "createReaderCollection got called once"); t.deepEqual(resourceFactory.createReaderCollection.getCall(0).args[0], { - name: "Dependency reader collection of project project.b", + name: "Reduced dependency reader collection of project project.b", readers: [ - "dep.a reader", "dep.b reader", "transitive.dep.a reader" + "dep.a reader", "dep.b reader", "dep.c reader" ] }, "createReaderCollection got called with correct arguments"); }); test("Custom task is called correctly", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(false); const mockSpecVersion = { @@ -654,7 +685,8 @@ test("Custom task is called correctly", async (t) => { graph.getExtension.returns({ getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); t.context.taskUtil.getInterface.returns("taskUtil interface"); const project = getMockProject("module"); @@ -663,39 +695,39 @@ test("Custom task is called correctly", async (t) => { ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); t.truthy(taskRunner._tasks["myTask"], "Custom tasks has been added to task map"); t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.a", "dep.b"]), "Custom tasks requires all dependencies by default"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); - t.is(specVersionGteStub.callCount, 2, "SpecificationVersion#gte got called twice"); + t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); + t.is(specVersionGteStub.getCall(2).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); t.is(specVersionGteStub.getCall(0).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on second call"); + t.is(specVersionGteStub.getCall(1).args[0], "5.0", + "SpecificationVersion#gte got called with correct arguments on second call (differential updates check)"); - t.is(createDependencyReaderStub.callCount, 1, "_createDependenciesReader got called once"); + t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.a", "dep.b"]), - "_createDependenciesReader got called with correct arguments"); + "getDependenciesReader got called with correct arguments"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - options: { - projectName: "project.b", - projectNamespace: "project/b", - configuration: "configuration", - }, - taskUtil: "taskUtil interface" - }, "Task got called with one argument"); + const taskArgs = taskStub.getCall(0).args[0]; + t.is(taskArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskArgs.taskUtil, "taskUtil interface", "taskUtil is correct"); + t.is(taskArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskArgs.options.configuration, "configuration", "configuration is correct"); t.is(taskUtil.getInterface.callCount, 1, "taskUtil#getInterface got called once"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, @@ -703,7 +735,7 @@ test("Custom task is called correctly", async (t) => { }); test("Custom task with legacy spec version", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(false); const mockSpecVersion = { @@ -714,7 +746,8 @@ test("Custom task with legacy spec version", async (t) => { graph.getExtension.returns({ getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); t.context.taskUtil.getInterface.returns(undefined); // simulating no taskUtil for old specVersion const project = getMockProject("module"); @@ -723,7 +756,7 @@ test("Custom task with legacy spec version", async (t) => { ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -731,31 +764,31 @@ test("Custom task with legacy spec version", async (t) => { t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.a", "dep.b"]), "Custom tasks requires all dependencies by default"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); - t.is(specVersionGteStub.callCount, 2, "SpecificationVersion#gte got called twice"); + t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); + t.is(specVersionGteStub.getCall(2).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); t.is(specVersionGteStub.getCall(0).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on second call"); + t.is(specVersionGteStub.getCall(1).args[0], "5.0", + "SpecificationVersion#gte got called with correct arguments on second call (differential updates check)"); - t.is(createDependencyReaderStub.callCount, 1, "_createDependenciesReader got called once"); + t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.a", "dep.b"]), - "_createDependenciesReader got called with correct arguments"); + "getDependenciesReader got called with correct arguments"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - options: { - projectName: "project.b", - projectNamespace: "project/b", - configuration: "configuration", - } - }, "Task got called with one argument"); + const taskArgs = taskStub.getCall(0).args[0]; + t.is(taskArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskArgs.options.configuration, "configuration", "configuration is correct"); t.is(taskUtil.getInterface.callCount, 1, "taskUtil#getInterface got called once"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, @@ -763,7 +796,7 @@ test("Custom task with legacy spec version", async (t) => { }); test("Custom task with legacy spec version and requiredDependenciesCallback", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(false); const mockSpecVersion = { @@ -775,7 +808,8 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as graph.getExtension.returns({ getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); t.context.taskUtil.getInterface.returns(undefined); // simulating no taskUtil for old specVersion const project = getMockProject("module"); @@ -784,7 +818,7 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -803,31 +837,31 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as } }, "requiredDependenciesCallback got called with expected arguments"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); - t.is(specVersionGteStub.callCount, 2, "SpecificationVersion#gte got called twice"); + t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); + t.is(specVersionGteStub.getCall(2).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); t.is(specVersionGteStub.getCall(0).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on second call"); + t.is(specVersionGteStub.getCall(1).args[0], "5.0", + "SpecificationVersion#gte got called with correct arguments on second call (differential updates check)"); - t.is(createDependencyReaderStub.callCount, 1, "_createDependenciesReader got called once"); + t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.b"]), - "_createDependenciesReader got called with correct arguments"); + "getDependenciesReader got called with correct arguments"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - options: { - projectName: "project.b", - projectNamespace: "project/b", - configuration: "configuration", - } - }, "Task got called with one argument"); + const taskArgs = taskStub.getCall(0).args[0]; + t.is(taskArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskArgs.options.configuration, "configuration", "configuration is correct"); t.is(taskUtil.getInterface.callCount, 1, "taskUtil#getInterface got called once"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, @@ -835,7 +869,7 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as }); test("Custom task with specVersion 3.0", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -850,7 +884,8 @@ test("Custom task with specVersion 3.0", async (t) => { graph.getExtension.returns({ getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -859,7 +894,7 @@ test("Custom task with specVersion 3.0", async (t) => { ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -895,14 +930,17 @@ test("Custom task with specVersion 3.0", async (t) => { t.truthy(taskRunner._tasks["myTask"], "Custom tasks has been added to task map"); t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.b"]), "Custom tasks requires all dependencies by default"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); - t.is(specVersionGteStub.callCount, 2, "SpecificationVersion#gte got called twice"); + t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); + t.is(specVersionGteStub.getCall(2).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); t.is(specVersionGteStub.getCall(0).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on second call"); + t.is(specVersionGteStub.getCall(1).args[0], "5.0", + "SpecificationVersion#gte got called with correct arguments on second call (differential updates check)"); t.is(taskUtil.getInterface.callCount, 2, "taskUtil#getInterface got called twice"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, @@ -910,29 +948,26 @@ test("Custom task with specVersion 3.0", async (t) => { t.is(taskUtil.getInterface.getCall(1).args[0], mockSpecVersion, "taskUtil#getInterface got called with correct argument on second call"); - t.is(createDependencyReaderStub.callCount, 1, "_createDependenciesReader got called once"); + t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.b"]), - "_createDependenciesReader got called with correct arguments"); + "getDependenciesReader got called with correct arguments"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - log: "group logger", - taskUtil, - options: { - projectName: "project.b", - projectNamespace: "project/b", - taskName: "myTask", // specVersion 3.0 feature - configuration: "configuration", - }, - }, "Task got called with one argument"); + const taskArgs = taskStub.getCall(0).args[0]; + t.is(taskArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskArgs.log, "group logger", "log is correct"); + t.deepEqual(taskArgs.taskUtil, taskUtil, "taskUtil is correct"); + t.is(taskArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskArgs.options.taskName, "myTask", "taskName is correct"); + t.is(taskArgs.options.configuration, "configuration", "configuration is correct"); }); test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", async (t) => { - const {sinon, graph, taskUtil, taskRepository, projectBuildLogger, cache, TaskRunner} = t.context; + const {sinon, graph, taskUtil, taskRepository, projectBuildLogger, buildCache, TaskRunner} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -946,7 +981,8 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy getName: () => "custom task name", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -955,45 +991,45 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); t.truthy(taskRunner._tasks["myTask"], "Custom tasks has been added to task map"); t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(), "Custom tasks requires no dependencies by default"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); - t.is(specVersionGteStub.callCount, 2, "SpecificationVersion#gte got called twice"); + t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); + t.is(specVersionGteStub.getCall(2).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); t.is(specVersionGteStub.getCall(0).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on second call"); + t.is(specVersionGteStub.getCall(1).args[0], "5.0", + "SpecificationVersion#gte got called with correct arguments on second call (differential updates check)"); t.is(taskUtil.getInterface.callCount, 1, "taskUtil#getInterface got called once"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, "taskUtil#getInterface got called with correct argument on first call"); - t.is(createDependencyReaderStub.callCount, 0, "_createDependenciesReader did not get called"); + t.is(createDependencyReaderStub.callCount, 0, "getDependenciesReader did not get called"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - log: "group logger", - taskUtil, - options: { - projectName: "project.b", - projectNamespace: "project/b", - taskName: "myTask", // specVersion 3.0 feature - configuration: "configuration", - }, - }, "Task got called with one argument"); + const taskArgs = taskStub.getCall(0).args[0]; + t.is(taskArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskArgs.log, "group logger", "log is correct"); + t.deepEqual(taskArgs.taskUtil, taskUtil, "taskUtil is correct"); + t.is(taskArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskArgs.options.taskName, "myTask", "taskName is correct"); + t.is(taskArgs.options.configuration, "configuration", "configuration is correct"); }); test("Multiple custom tasks with same name are called correctly", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStubA = sinon.stub(); const taskStubB = sinon.stub(); const taskStubC = sinon.stub(); @@ -1025,25 +1061,29 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { getName: () => "Task Name A", getTask: () => taskStubA, getSpecVersion: () => mockSpecVersionA, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); graph.getExtension.onSecondCall().returns({ getName: () => "Task Name B", getTask: () => taskStubB, getSpecVersion: () => mockSpecVersionB, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); graph.getExtension.onThirdCall().returns({ getName: () => "Task Name C", getTask: () => taskStubC, getSpecVersion: () => mockSpecVersionC, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); graph.getExtension.onCall(3).returns({ getName: () => "Task Name D", getTask: () => taskStubD, getSpecVersion: () => mockSpecVersionD, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); project.getCustomTasks = () => [ @@ -1053,7 +1093,7 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { {name: "myTask", afterTask: "myTask", configuration: "bird"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -1089,7 +1129,8 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { "myTask--2", ], "Correct order of custom tasks"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); await taskRunner.runTasks(); t.is(projectBuildLogger.setTasks.callCount, 1, "ProjectBuildLogger#setTask got called once"); @@ -1127,75 +1168,64 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { t.is(taskUtil.getInterface.getCall(4).args[0], mockSpecVersionB, "taskUtil#getInterface got called with correct argument on fifth call"); - t.is(createDependencyReaderStub.callCount, 3, "_createDependenciesReader got called three times"); + t.is(createDependencyReaderStub.callCount, 4, "getDependenciesReader got called four times"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], - new Set(["dep.b"]), - "_createDependenciesReader got called with correct arguments on first call"); + new Set(["dep.a", "dep.b"]), + "getDependenciesReader got called with correct arguments on first call (runTasks init)"); t.deepEqual(createDependencyReaderStub.getCall(1).args[0], - new Set(["dep.a"]), - "_createDependenciesReader got called with correct arguments on second call"); + new Set(["dep.b"]), + "getDependenciesReader got called with correct arguments on second call (Task A)"); t.deepEqual(createDependencyReaderStub.getCall(2).args[0], + new Set(["dep.a"]), + "getDependenciesReader got called with correct arguments on third call (Task D)"); + t.deepEqual(createDependencyReaderStub.getCall(3).args[0], new Set(["dep.a", "dep.b"]), - "_createDependenciesReader got called with correct arguments on third call"); + "getDependenciesReader got called with correct arguments on fourth call (Task B)"); t.is(taskStubA.callCount, 1, "Task A got called once"); t.is(taskStubA.getCall(0).args.length, 1, "Task A got called with one argument"); - t.deepEqual(taskStubA.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - taskUtil, - options: { - projectName: "project.b", - projectNamespace: "project/b", - configuration: "cat", - } - }, "Task A got called with one argument"); + const taskAArgs = taskStubA.getCall(0).args[0]; + t.is(taskAArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskAArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskAArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskAArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskAArgs.options.configuration, "cat", "configuration is correct"); t.is(taskStubB.callCount, 1, "Task B got called once"); t.is(taskStubB.getCall(0).args.length, 1, "Task B got called with one argument"); - t.deepEqual(taskStubB.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - taskUtil, - options: { - projectName: "project.b", - projectNamespace: "project/b", - configuration: "dog", - } - }, "Task B got called with one argument"); + const taskBArgs = taskStubB.getCall(0).args[0]; + t.is(taskBArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskBArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskBArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskBArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskBArgs.options.configuration, "dog", "configuration is correct"); t.is(taskStubC.callCount, 1, "Task C got called once"); t.is(taskStubC.getCall(0).args.length, 1, "Task C got called with one argument"); - t.deepEqual(taskStubC.getCall(0).args[0], { - workspace: "workspace", - log: "group logger", - taskUtil, - options: { - projectName: "project.b", - projectNamespace: "project/b", - taskName: "myTask--3", - configuration: "bird", - } - }, "Task C got called with one argument"); + const taskCArgs = taskStubC.getCall(0).args[0]; + t.is(taskCArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskCArgs.log, "group logger", "log is correct"); + t.deepEqual(taskCArgs.taskUtil, taskUtil, "taskUtil is correct"); + t.is(taskCArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskCArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskCArgs.options.taskName, "myTask--3", "taskName is correct"); + t.is(taskCArgs.options.configuration, "bird", "configuration is correct"); t.is(taskStubD.callCount, 1, "Task D got called once"); t.is(taskStubD.getCall(0).args.length, 1, "Task D got called with one argument"); - t.deepEqual(taskStubD.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - log: "group logger", - taskUtil, - options: { - projectName: "project.b", - projectNamespace: "project/b", - taskName: "myTask--4", - configuration: "bird", - } - }, "Task D got called with one argument"); + const taskDArgs = taskStubD.getCall(0).args[0]; + t.is(taskDArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskDArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskDArgs.log, "group logger", "log is correct"); + t.deepEqual(taskDArgs.taskUtil, taskUtil, "taskUtil is correct"); + t.is(taskDArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskDArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskDArgs.options.taskName, "myTask--4", "taskName is correct"); + t.is(taskDArgs.options.configuration, "bird", "configuration is correct"); }); test("Custom task: requiredDependenciesCallback returns unknown dependency", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -1211,7 +1241,8 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy getName: () => "custom.task.a", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -1220,7 +1251,7 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await t.throwsAsync(taskRunner._initTasks(), { message: @@ -1232,7 +1263,7 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy test("Custom task: requiredDependenciesCallback returns Array instead of Set", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const specVersionGteStub = sinon.stub().returns(true); const mockSpecVersion = { @@ -1248,7 +1279,8 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a getName: () => "custom.task.a", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -1257,7 +1289,7 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await t.throwsAsync(taskRunner._initTasks(), { message: @@ -1267,7 +1299,7 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a }); test("Custom task attached to a disabled task", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache, sinon, customTask} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache, sinon, customTask} = t.context; const project = getMockProject("application"); const customTaskFnStub = sinon.stub(); @@ -1280,7 +1312,7 @@ test("Custom task attached to a disabled task", async (t) => { customTask.getTask = () => customTaskFnStub; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner.runTasks(); @@ -1307,7 +1339,7 @@ test("Custom task attached to a disabled task", async (t) => { }); test.serial("_addTask", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); taskRepository.getTask.withArgs("standardTask").resolves({ @@ -1316,7 +1348,7 @@ test.serial("_addTask", async (t) => { const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -1337,24 +1369,20 @@ test.serial("_addTask", async (t) => { t.is(taskRepository.getTask.getCall(0).args[0], "standardTask", "taskRepository#getTask got called with correct argument"); t.is(taskStub.callCount, 1, "Task got called once"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - // No dependencies - options: { - projectName: "project.b", - projectNamespace: "project/b" - }, - taskUtil - }, "Task got called with correct arguments"); + const taskCallArgs = taskStub.getCall(0).args[0]; + t.is(taskCallArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskCallArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskCallArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskCallArgs.taskUtil, taskUtil, "taskUtil is correct"); }); test.serial("_addTask with options", async (t) => { - const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {sinon, graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const taskStub = sinon.stub(); const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -1372,33 +1400,31 @@ test.serial("_addTask with options", async (t) => { t.truthy(taskRunner._tasks["standardTask"].task, "Task function got set correctly"); t.deepEqual(taskRunner._taskExecutionOrder, ["standardTask"], "Task got added to execution order"); - const createDependencyReaderStub = sinon.stub(taskRunner, "_createDependenciesReader").resolves("dependencies"); - await taskRunner._tasks["standardTask"].task({ - workspace: "workspace", - dependencies: "dependencies", - }); + // Warm the cache (normally done by runTasks) + await taskRunner.getDependenciesReader(new Set(["dep.a", "dep.b"]), true); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); + // Call the task wrapper without parameters (it creates workspace/dependencies internally) + await taskRunner._tasks["standardTask"].task(); t.is(taskRepository.getTask.callCount, 0, "taskRepository#getTask did not get called"); - t.is(createDependencyReaderStub.callCount, 0, "_createDependenciesReader did not get called"); + t.is(createDependencyReaderStub.callCount, 0, "getDependenciesReader did not get called (using cached reader)"); t.is(taskStub.callCount, 1, "Task got called once"); - t.deepEqual(taskStub.getCall(0).args[0], { - workspace: "workspace", - dependencies: taskRunner._allDependenciesReader, - options: { - projectName: "project.b", - projectNamespace: "project/b", - myTaskOption: "cat" - }, - taskUtil - }, "Task got called with correct arguments"); + const taskCallArgs = taskStub.getCall(0).args[0]; + t.is(taskCallArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); + t.is(taskCallArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); + t.is(taskCallArgs.options.projectName, "project.b", "projectName is correct"); + t.is(taskCallArgs.options.projectNamespace, "project/b", "projectNamespace is correct"); + t.is(taskCallArgs.options.myTaskOption, "cat", "myTaskOption is correct"); + t.is(taskCallArgs.taskUtil, taskUtil, "taskUtil is correct"); }); test("_addTask: Duplicate task", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -1416,10 +1442,10 @@ test("_addTask: Duplicate task", async (t) => { }); test("_addTask: Task already added to execution order", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); @@ -1435,13 +1461,13 @@ test("_addTask: Task already added to execution order", async (t) => { }); test("getRequiredDependencies: Custom Task", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); project.getCustomTasks = () => [ {name: "myTask"} ]; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Project with custom task >= specVersion 3.0 and no requiredDependenciesCallback " + @@ -1449,72 +1475,72 @@ test("getRequiredDependencies: Custom Task", async (t) => { }); test("getRequiredDependencies: Default application", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("application"); project.getBundles = () => []; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Default application project does not require dependencies"); }); test("getRequiredDependencies: Default component", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("component"); project.getBundles = () => []; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Default component project does not require dependencies"); }); test("getRequiredDependencies: Default library", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("library"); project.getBundles = () => []; const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set(["dep.a", "dep.b"]), "Default library project requires dependencies"); }); test("getRequiredDependencies: Default theme-library", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("theme-library"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set(["dep.a", "dep.b"]), "Default theme-library project requires dependencies"); }); test("getRequiredDependencies: Default module", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, cache} = t.context; + const {graph, taskUtil, taskRepository, TaskRunner, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), "Default module project does not require dependencies"); }); -test("_createDependenciesReader", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; +test("getDependenciesReader", async (t) => { + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); graph.traverseBreadthFirst.reset(); // Ignore the call in initTask resourceFactory.createReaderCollection.reset(); // Ignore the call in initTask - resourceFactory.createReaderCollection.returns("custom reader collection"); - const res = await taskRunner._createDependenciesReader(new Set(["dep.a"])); + resourceFactory.createReaderCollection.returns({getName: () => "custom reader collection"}); + const res = await taskRunner.getDependenciesReader(new Set(["dep.a"])); t.is(graph.traverseBreadthFirst.callCount, 1, "ProjectGraph#traverseBreadthFirst got called once"); t.is(graph.traverseBreadthFirst.getCall(0).args[0], "project.b", @@ -1561,42 +1587,45 @@ test("_createDependenciesReader", async (t) => { "dep.a reader", "dep.b reader", "dep.c reader" ] }, "createReaderCollection got called with correct arguments"); - t.is(res, "custom reader collection", "Returned expected value"); + t.is(res.getName(), "custom reader collection", "Returned expected value"); }); -test("_createDependenciesReader: All dependencies required", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; +test("getDependenciesReader: All dependencies required", async (t) => { + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); - graph.traverseBreadthFirst.reset(); // Ignore the call in initTask - resourceFactory.createReaderCollection.reset(); // Ignore the call in initTask - resourceFactory.createReaderCollection.returns("custom reader collection"); - const res = await taskRunner._createDependenciesReader(new Set(["dep.a", "dep.b"])); + // Initialize the cache by calling getDependenciesReader with a subset first to avoid the shortcut + // Then call with forceUpdate to populate the cache + const cachedReader = await taskRunner.getDependenciesReader(new Set(["dep.a", "dep.b"]), true); + graph.traverseBreadthFirst.reset(); // Ignore the call in init + resourceFactory.createReaderCollection.reset(); // Ignore the call in init + resourceFactory.createReaderCollection.returns({getName: () => "custom reader collection"}); + const res = await taskRunner.getDependenciesReader(new Set(["dep.a", "dep.b"])); t.is(graph.traverseBreadthFirst.callCount, 0, "ProjectGraph#traverseBreadthFirst did not get called again"); t.is(resourceFactory.createReaderCollection.callCount, 0, "createReaderCollection did not get called again"); - t.is(res, "reader collection", "Shared (all-)dependency reader returned"); + t.is(res, cachedReader, "Shared (all-)dependency reader returned"); }); -test("_createDependenciesReader: No dependencies required", async (t) => { - const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, cache} = t.context; +test("getDependenciesReader: No dependencies required", async (t) => { + const {graph, taskUtil, taskRepository, TaskRunner, resourceFactory, projectBuildLogger, buildCache} = t.context; const project = getMockProject("module"); const taskRunner = new TaskRunner({ - project, graph, taskUtil, taskRepository, log: projectBuildLogger, cache, buildConfig + project, graph, taskUtil, taskRepository, log: projectBuildLogger, buildCache, buildConfig }); await taskRunner._initTasks(); graph.traverseBreadthFirst.reset(); // Ignore the call in initTask resourceFactory.createReaderCollection.reset(); // Ignore the call in initTask - resourceFactory.createReaderCollection.returns("custom reader collection"); - const res = await taskRunner._createDependenciesReader(new Set()); + resourceFactory.createReaderCollection.returns({getName: () => "custom reader collection"}); + const res = await taskRunner.getDependenciesReader(new Set()); t.is(graph.traverseBreadthFirst.callCount, 1, "ProjectGraph#traverseBreadthFirst got called once"); t.is(resourceFactory.createReaderCollection.callCount, 1, "createReaderCollection got called once"); t.deepEqual(resourceFactory.createReaderCollection.getCall(0).args[0].readers, [], "createReaderCollection got called with no readers"); - t.is(res, "custom reader collection", "Shared (all-)dependency reader returned"); + t.is(res.getName(), "custom reader collection", "Shared (all-)dependency reader returned"); }); From 9b6a63cde9c8cc9873e8899610c2dc13e141d73d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Jan 2026 17:48:43 +0100 Subject: [PATCH 131/223] test(project): Update various tests --- packages/project/lib/build/ProjectBuilder.js | 2 +- .../project/lib/build/helpers/BuildContext.js | 2 -- .../lib/build/helpers/createBuildManifest.js | 8 ++--- .../project/test/lib/build/ProjectBuilder.js | 9 ++--- .../test/lib/build/definitions/application.js | 33 ++++++++++++------ .../test/lib/build/definitions/component.js | 21 ++++++++---- .../test/lib/build/definitions/library.js | 34 +++++++++++++------ .../lib/build/definitions/themeLibrary.js | 12 ++++--- .../lib/build/helpers/composeProjectList.js | 20 +++++------ .../createBuildManifest.integration.js | 4 +-- .../lib/build/helpers/createBuildManifest.js | 21 +++++++++--- .../test/lib/specifications/types/Library.js | 2 +- 12 files changed, 106 insertions(+), 62 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 75bee1539be..3657b12d27f 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -468,7 +468,7 @@ class ProjectBuilder { default: createBuildManifest } = await import("./helpers/createBuildManifest.js"); const buildManifest = await createBuildManifest( - project, this._graph, buildConfig, this._buildContext.getTaskRepository(), + project, buildConfig, this._buildContext.getTaskRepository(), projectBuildContext.getBuildSignature()); await target.write(resourceFactory.createResource({ path: `/.ui5/build-manifest.json`, diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 300553042f7..791e0cbaf70 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -17,7 +17,6 @@ class BuildContext { cssVariables = false, jsdoc = false, createBuildManifest = false, - useCache = false, outputStyle = OutputStyleEnum.Default, includedTasks = [], excludedTasks = [], } = {}) { @@ -72,7 +71,6 @@ class BuildContext { outputStyle, includedTasks, excludedTasks, - useCache, }; this._buildSignatureBase = getBaseSignature(this._buildConfig); diff --git a/packages/project/lib/build/helpers/createBuildManifest.js b/packages/project/lib/build/helpers/createBuildManifest.js index 2f95b6fa363..7ab4d4244da 100644 --- a/packages/project/lib/build/helpers/createBuildManifest.js +++ b/packages/project/lib/build/helpers/createBuildManifest.js @@ -16,19 +16,19 @@ function getSortedTags(project) { return Object.fromEntries(entities); } -export default async function(project, graph, buildConfig, taskRepository, signature) { +export default async function(project, buildConfig, taskRepository, signature) { if (!project) { throw new Error(`Missing parameter 'project'`); } - if (!graph) { - throw new Error(`Missing parameter 'graph'`); - } if (!buildConfig) { throw new Error(`Missing parameter 'buildConfig'`); } if (!taskRepository) { throw new Error(`Missing parameter 'taskRepository'`); } + if (!signature) { + throw new Error(`Missing parameter 'signature'`); + } const projectName = project.getName(); const type = project.getType(); diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index 9401ab7646f..116363a8d50 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -585,9 +585,7 @@ test.serial("_writeResults: Create build manifest", async (t) => { t.is(createBuildManifestStub.callCount, 1, "createBuildManifest got called once"); t.is(createBuildManifestStub.getCall(0).args[0], mockProject, "createBuildManifest got called with correct project"); - t.is(createBuildManifestStub.getCall(0).args[1], graph, - "createBuildManifest got called with correct graph"); - t.deepEqual(createBuildManifestStub.getCall(0).args[2], { + t.deepEqual(createBuildManifestStub.getCall(0).args[1], { createBuildManifest: true, outputStyle: OutputStyleEnum.Default, cssVariables: false, @@ -595,11 +593,10 @@ test.serial("_writeResults: Create build manifest", async (t) => { includedTasks: [], jsdoc: false, selfContained: false, - useCache: false, }, "createBuildManifest got called with correct build configuration"); - t.is(createBuildManifestStub.getCall(0).args[3], taskRepository, + t.is(createBuildManifestStub.getCall(0).args[2], taskRepository, "createBuildManifest got called with correct taskRepository"); - t.is(createBuildManifestStub.getCall(0).args[4], "build-signature", + t.is(createBuildManifestStub.getCall(0).args[3], "build-signature", "createBuildManifest got called with correct buildSignature"); t.is(createResourceStub.callCount, 1, "One resource has been created"); diff --git a/packages/project/test/lib/build/definitions/application.js b/packages/project/test/lib/build/definitions/application.js index 742d398e988..e44ef37b1d1 100644 --- a/packages/project/test/lib/build/definitions/application.js +++ b/packages/project/test/lib/build/definitions/application.js @@ -57,12 +57,14 @@ test("Standard build", (t) => { replaceCopyright: { options: { copyright: "copyright", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, minify: { options: { @@ -70,7 +72,8 @@ test("Standard build", (t) => { "/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -137,12 +140,14 @@ test("Standard build with legacy spec version", (t) => { replaceCopyright: { options: { copyright: "copyright", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, minify: { options: { @@ -150,7 +155,8 @@ test("Standard build with legacy spec version", (t) => { "/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -250,12 +256,14 @@ test("Custom bundles", async (t) => { replaceCopyright: { options: { copyright: "copyright", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, minify: { options: { @@ -263,7 +271,8 @@ test("Custom bundles", async (t) => { "/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -396,7 +405,8 @@ test("Minification excludes", (t) => { "!**/*.support.js", "!/resources/**.html", ] - } + }, + supportsDifferentialUpdates: true, }, "Correct minify task definition"); }); @@ -421,7 +431,8 @@ test("Minification excludes not applied for legacy specVersion", (t) => { "/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, "Correct minify task definition"); }); diff --git a/packages/project/test/lib/build/definitions/component.js b/packages/project/test/lib/build/definitions/component.js index abb281a86ed..9be7b4d5549 100644 --- a/packages/project/test/lib/build/definitions/component.js +++ b/packages/project/test/lib/build/definitions/component.js @@ -56,12 +56,14 @@ test("Standard build", (t) => { replaceCopyright: { options: { copyright: "copyright", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, minify: { options: { @@ -69,7 +71,8 @@ test("Standard build", (t) => { "/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -162,12 +165,14 @@ test("Custom bundles", async (t) => { replaceCopyright: { options: { copyright: "copyright", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" - } + }, + supportsDifferentialUpdates: true, }, minify: { options: { @@ -175,7 +180,8 @@ test("Custom bundles", async (t) => { "/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -301,7 +307,8 @@ test("Minification excludes", (t) => { "!**/*.support.js", "!/resources/**.html", ] - } + }, + supportsDifferentialUpdates: true, }, "Correct minify task definition"); }); diff --git a/packages/project/test/lib/build/definitions/library.js b/packages/project/test/lib/build/definitions/library.js index 121e8951442..8a56555cb1f 100644 --- a/packages/project/test/lib/build/definitions/library.js +++ b/packages/project/test/lib/build/definitions/library.js @@ -74,12 +74,14 @@ test("Standard build", async (t) => { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" - } + }, + supportsDifferentialUpdates: true, }, generateJsdoc: { requiresDependencies: true, @@ -98,6 +100,7 @@ test("Standard build", async (t) => { "!**/*.support.js", ] } + supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, enhanceManifest: {}, @@ -211,12 +214,14 @@ test("Standard build with legacy spec version", (t) => { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" - } + }, + supportsDifferentialUpdates: true, }, generateJsdoc: { requiresDependencies: true, @@ -235,6 +240,7 @@ test("Standard build with legacy spec version", (t) => { "!**/*.support.js", ] } + supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, enhanceManifest: {}, @@ -337,12 +343,14 @@ test("Custom bundles", async (t) => { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" - } + }, + supportsDifferentialUpdates: true, }, generateJsdoc: { requiresDependencies: true, @@ -361,6 +369,7 @@ test("Custom bundles", async (t) => { "!**/*.support.js", ] } + supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, enhanceManifest: {}, @@ -489,7 +498,8 @@ test("Minification excludes", (t) => { "!**/*.support.js", "!/resources/**.html", ] - } + }, + supportsDifferentialUpdates: true, }, "Correct minify task definition"); }); @@ -514,7 +524,8 @@ test("Minification excludes not applied for legacy specVersion", (t) => { "/resources/**/*.js", "!**/*.support.js", ] - } + }, + supportsDifferentialUpdates: true, }, "Correct minify task definition"); }); @@ -681,12 +692,14 @@ test("Standard build: nulled taskFunction to skip tasks", (t) => { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" - } + }, + supportsDifferentialUpdates: true, }, generateJsdoc: { requiresDependencies: true, @@ -705,6 +718,7 @@ test("Standard build: nulled taskFunction to skip tasks", (t) => { "!**/*.support.js", ] } + supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, enhanceManifest: {}, diff --git a/packages/project/test/lib/build/definitions/themeLibrary.js b/packages/project/test/lib/build/definitions/themeLibrary.js index 2da2457b538..6201d67e318 100644 --- a/packages/project/test/lib/build/definitions/themeLibrary.js +++ b/packages/project/test/lib/build/definitions/themeLibrary.js @@ -53,13 +53,15 @@ test("Standard build", (t) => { options: { copyright: "copyright", pattern: "/resources/**/*.{less,theme}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/resources/**/*.{less,theme}" - } + }, + supportsDifferentialUpdates: true, }, buildThemes: { requiresDependencies: true, @@ -114,13 +116,15 @@ test("Standard build for non root project", (t) => { options: { copyright: "copyright", pattern: "/resources/**/*.{less,theme}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { version: "version", pattern: "/resources/**/*.{less,theme}" - } + }, + supportsDifferentialUpdates: true, }, buildThemes: { requiresDependencies: true, diff --git a/packages/project/test/lib/build/helpers/composeProjectList.js b/packages/project/test/lib/build/helpers/composeProjectList.js index f8f58185f38..8556efcdfbb 100644 --- a/packages/project/test/lib/build/helpers/composeProjectList.js +++ b/packages/project/test/lib/build/helpers/composeProjectList.js @@ -224,9 +224,9 @@ test.serial("createDependencyLists: include all", async (t) => { excludeDependencyRegExp: [], excludeDependencyTree: [], expectedIncludedDependencies: [ - "library.d", "library.b", "library.c", - "library.d-depender", "library.a", "library.g", - "library.e", "library.f" + "library.d", "library.b", "library.a", + "library.e", "library.c", "library.f", + "library.d-depender", "library.g" ], expectedExcludedDependencies: [] }); @@ -239,7 +239,7 @@ test.serial("createDependencyLists: includeDependencyTree has lower priority tha excludeDependency: ["library.f"], excludeDependencyRegExp: ["^library\\.[acd]$"], expectedIncludedDependencies: ["library.b"], - expectedExcludedDependencies: ["library.f", "library.d", "library.c", "library.a"] + expectedExcludedDependencies: ["library.f", "library.d", "library.a", "library.c"] }); }); @@ -249,7 +249,7 @@ test.serial("createDependencyLists: excludeDependencyTree has lower priority tha includeDependency: ["library.f"], includeDependencyRegExp: ["^library\\.[acd]$"], excludeDependencyTree: ["library.f"], - expectedIncludedDependencies: ["library.f", "library.d", "library.c", "library.a"], + expectedIncludedDependencies: ["library.f", "library.d", "library.a", "library.c"], expectedExcludedDependencies: ["library.b"] }); }); @@ -261,8 +261,8 @@ test.serial("createDependencyLists: include all, exclude tree and include single includeDependencyRegExp: ["^library\\.[acd]$"], excludeDependencyTree: ["library.f"], expectedIncludedDependencies: [ - "library.f", "library.d", "library.c", "library.a", "library.d-depender", - "library.g", "library.e" + "library.f", "library.d", "library.a", "library.c", "library.e", + "library.d-depender", "library.g" ], expectedExcludedDependencies: ["library.b"] }); @@ -287,7 +287,7 @@ test.serial("createDependencyLists: defaultIncludeDependency/RegExp has lower pr excludeDependency: ["library.f"], excludeDependencyRegExp: ["^library\\.[acd](-depender)?$"], expectedIncludedDependencies: ["library.b"], - expectedExcludedDependencies: ["library.f", "library.d", "library.c", "library.d-depender", "library.a"] + expectedExcludedDependencies: ["library.f", "library.d", "library.a", "library.c", "library.d-depender"] }); }); test.serial("createDependencyLists: include all and defaultIncludeDependency/RegExp", async (t) => { @@ -297,8 +297,8 @@ test.serial("createDependencyLists: include all and defaultIncludeDependency/Reg defaultIncludeDependencyRegExp: ["^library\\.d$"], excludeDependency: ["library.f"], excludeDependencyRegExp: ["^library\\.[acd](-depender)?$"], - expectedIncludedDependencies: ["library.b", "library.g", "library.e"], - expectedExcludedDependencies: ["library.f", "library.d", "library.c", "library.d-depender", "library.a"] + expectedIncludedDependencies: ["library.b", "library.e", "library.g"], + expectedExcludedDependencies: ["library.f", "library.d", "library.a", "library.c", "library.d-depender"] }); }); diff --git a/packages/project/test/lib/build/helpers/createBuildManifest.integration.js b/packages/project/test/lib/build/helpers/createBuildManifest.integration.js index 015ca68bd1d..2ff65d198ef 100644 --- a/packages/project/test/lib/build/helpers/createBuildManifest.integration.js +++ b/packages/project/test/lib/build/helpers/createBuildManifest.integration.js @@ -51,7 +51,7 @@ test("Create project from application project providing a build manifest", async getVersions: async () => ({a: "a", b: "b"}) }; - const metadata = await createBuildManifest(inputProject, buildConfig, taskRepository); + const metadata = await createBuildManifest(inputProject, buildConfig, taskRepository, "yyy"); const m = new Module({ id: "build-descr-application.a.id", version: "2.0.0", @@ -83,7 +83,7 @@ test("Create project from library project providing a build manifest", async (t) getVersions: async () => ({a: "a", b: "b"}) }; - const metadata = await createBuildManifest(inputProject, buildConfig, taskRepository); + const metadata = await createBuildManifest(inputProject, buildConfig, taskRepository, "zzz"); const m = new Module({ id: "build-descr-library.e.id", version: "2.0.0", diff --git a/packages/project/test/lib/build/helpers/createBuildManifest.js b/packages/project/test/lib/build/helpers/createBuildManifest.js index 7b2266b8897..5a9e17df114 100644 --- a/packages/project/test/lib/build/helpers/createBuildManifest.js +++ b/packages/project/test/lib/build/helpers/createBuildManifest.js @@ -64,6 +64,17 @@ test("Missing parameter: taskRepository", async (t) => { }); }); +test("Missing parameter: signature", async (t) => { + const project = await Specification.create(applicationProjectInput); + + const taskRepository = { + getVersions: async () => ({builderVersion: "", fsVersion: ""}) + }; + await t.throwsAsync(createBuildManifest(project, "buildConfig", taskRepository), { + message: "Missing parameter 'signature'" + }); +}); + test("Create application from project with build manifest", async (t) => { const project = await Specification.create(applicationProjectInput); project.getResourceTagCollection().setTag("/resources/id1/foo.js", "ui5:HasDebugVariant"); @@ -72,7 +83,7 @@ test("Create application from project with build manifest", async (t) => { getVersions: async () => ({builderVersion: "", fsVersion: ""}) }; - const metadata = await createBuildManifest(project, "buildConfig", taskRepository); + const metadata = await createBuildManifest(project, "buildConfig", taskRepository, "yyy"); t.truthy(new Date(metadata.buildManifest.timestamp), "Timestamp is valid"); metadata.buildManifest.timestamp = ""; @@ -99,7 +110,8 @@ test("Create application from project with build manifest", async (t) => { } }, buildManifest: { - manifestVersion: "0.2", + manifestVersion: "1.0", + signature: "yyy", buildConfig: "buildConfig", namespace: "id1", timestamp: "", @@ -127,7 +139,7 @@ test("Create library from project with build manifest", async (t) => { getVersions: async () => ({builderVersion: "", fsVersion: ""}) }; - const metadata = await createBuildManifest(project, "buildConfig", taskRepository); + const metadata = await createBuildManifest(project, "buildConfig", taskRepository, "zzz"); t.truthy(new Date(metadata.buildManifest.timestamp), "Timestamp is valid"); metadata.buildManifest.timestamp = ""; @@ -155,7 +167,8 @@ test("Create library from project with build manifest", async (t) => { } }, buildManifest: { - manifestVersion: "0.2", + manifestVersion: "1.0", + signature: "zzz", buildConfig: "buildConfig", namespace: "library/d", timestamp: "", diff --git a/packages/project/test/lib/specifications/types/Library.js b/packages/project/test/lib/specifications/types/Library.js index aaeed466701..fc7f4d339b3 100644 --- a/packages/project/test/lib/specifications/types/Library.js +++ b/packages/project/test/lib/specifications/types/Library.js @@ -480,7 +480,7 @@ test("_parseConfiguration: Get copyright", async (t) => { const {projectInput} = t.context; const project = await (new Library().init(projectInput)); - t.is(project.getCopyright(), "Some fancy copyright", "Copyright was read correctly"); + t.is(project.getCopyright(), "${copyright}", "Copyright was read correctly"); }); test("_parseConfiguration: Copyright already configured", async (t) => { From a64759a227c8b6d01ec3846a7e47b2f9ff00238d Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 27 Jan 2026 09:14:16 +0100 Subject: [PATCH 132/223] test(project): Add missing comma --- packages/project/test/lib/build/definitions/library.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/project/test/lib/build/definitions/library.js b/packages/project/test/lib/build/definitions/library.js index 8a56555cb1f..64546bb6200 100644 --- a/packages/project/test/lib/build/definitions/library.js +++ b/packages/project/test/lib/build/definitions/library.js @@ -99,7 +99,7 @@ test("Standard build", async (t) => { "/resources/**/*.js", "!**/*.support.js", ] - } + }, supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, @@ -239,7 +239,7 @@ test("Standard build with legacy spec version", (t) => { "/resources/**/*.js", "!**/*.support.js", ] - } + }, supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, @@ -368,7 +368,7 @@ test("Custom bundles", async (t) => { "/resources/**/*.js", "!**/*.support.js", ] - } + }, supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, @@ -717,7 +717,7 @@ test("Standard build: nulled taskFunction to skip tasks", (t) => { "/resources/**/*.js", "!**/*.support.js", ] - } + }, supportsDifferentialUpdates: true, }, generateLibraryManifest: {}, From 44f04f2964049dabd0eff30c2d183bd6083296bf Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 27 Jan 2026 10:46:55 +0100 Subject: [PATCH 133/223] refactor(project): Improve abort signal handling --- packages/project/lib/build/BuildServer.js | 31 ++++++++++++-------- packages/project/lib/build/ProjectBuilder.js | 12 +++----- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 47bfb7f8f80..547341c99e4 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -5,6 +5,13 @@ import WatchHandler from "./helpers/WatchHandler.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:BuildServer"); +class AbortBuildError extends Error { + constructor(message) { + super(message); + this.name = "AbortBuildError"; + } +}; + /** * Development server that provides access to built project resources with automatic rebuilding * @@ -255,7 +262,7 @@ class BuildServer extends EventEmitter { #projectResourceChangedLive(project, fileAddedOrRemoved) { for (const {project: affectedProject} of this.#graph.traverseDependents(project.getName(), true)) { const projectBuildStatus = this.#projectBuildStatus.get(affectedProject.getName()); - projectBuildStatus.abortBuild("Source files changed"); + projectBuildStatus.abortBuild(new AbortBuildError(`Source change in project '${project.getName()}'`)); if (fileAddedOrRemoved) { // Reset any cached readers in case files were added or removed projectBuildStatus.resetReaderCache(); @@ -353,13 +360,9 @@ class BuildServer extends EventEmitter { // Project has been built and result can be used const projectBuildStatus = this.#projectBuildStatus.get(projectName); projectBuildStatus.setReader(project.getReader({style: "runtime"})); - }); - - try { - const builtProjects = await buildPromise; - this.emit("buildFinished", builtProjects); - } catch (err) { - if (err.name === "AbortError") { + }).catch((err) => { + if (err instanceof AbortBuildError) { + log.info("Build aborted"); // Build was aborted - do not log as error // Re-queue any outstanding projects for (const projectName of projectsToBuild) { @@ -378,10 +381,12 @@ class BuildServer extends EventEmitter { // Re-throw to be handled by caller throw err; } - } finally { - // Clear active build - this.#activeBuild = null; - } + }); + + const builtProjects = await buildPromise; + this.emit("buildFinished", builtProjects); + // Clear active build + this.#activeBuild = null; if (signal.aborted) { log.verbose(`Build aborted for projects: ${projectsToBuild.join(", ")}`); return; @@ -405,7 +410,7 @@ class ProjectBuildStatus { invalidate() { this.#state = PROJECT_STATES.INVALIDATED; // Ensure any running build is aborted. Then reset the abort controller - this.#abortController.abort(); + this.#abortController.abort(new AbortBuildError("Project invalidated")); this.#abortController = new AbortController(); } diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 3657b12d27f..06050548e3b 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -304,11 +304,10 @@ class ProjectBuilder { } const cleanupSigHooks = this._registerCleanupSigHooks(); + const pCacheWrites = []; try { const startTime = process.hrtime(); - const pCacheWrites = []; while (queue.length) { - signal?.throwIfAborted(); const projectBuildContext = queue.shift(); const project = projectBuildContext.getProject(); const projectName = project.getName(); @@ -327,6 +326,7 @@ class ProjectBuilder { await this._buildProject(projectBuildContext); } } + signal?.throwIfAborted(); if (projectBuiltCallback && requestedProjects.includes(projectName)) { projectBuiltCallback(projectName, project, projectBuildContext); @@ -337,16 +337,12 @@ class ProjectBuilder { pCacheWrites.push(projectBuildContext.writeBuildCache()); } } - await Promise.all(pCacheWrites); this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); } catch (err) { - if (err.name === "AbortError") { - this.#log.info(`Build aborted. Reason: ${err.message}`); - } else { - this.#log.error(`Build failed`); - } + this.#log.error(`Build failed`); throw err; } finally { + await Promise.all(pCacheWrites); this._deregisterCleanupSigHooks(cleanupSigHooks); await this._executeCleanupTasks(); this.#buildIsRunning = false; From 27bf81e5241c477d910a35708213d66bbe135f67 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 27 Jan 2026 11:03:06 +0100 Subject: [PATCH 134/223] refactor(project): Fix additional tests --- packages/project/lib/specifications/Project.js | 2 +- .../project/test/lib/build/definitions/library.js | 12 ++++++++---- .../test/lib/specifications/types/Application.js | 3 ++- .../test/lib/specifications/types/Component.js | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index ffcabd2ead0..4e66c6a9eae 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -47,11 +47,11 @@ class Project extends Specification { async init(parameters) { await super.init(parameters); - this._initStageMetadata(); this._buildManifest = parameters.buildManifest; await this._configureAndValidatePaths(this._config); await this._parseConfiguration(this._config, this._buildManifest); + this._initStageMetadata(); return this; } diff --git a/packages/project/test/lib/build/definitions/library.js b/packages/project/test/lib/build/definitions/library.js index 64546bb6200..e49979b3b86 100644 --- a/packages/project/test/lib/build/definitions/library.js +++ b/packages/project/test/lib/build/definitions/library.js @@ -68,7 +68,8 @@ test("Standard build", async (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { @@ -208,7 +209,8 @@ test("Standard build with legacy spec version", (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { @@ -337,7 +339,8 @@ test("Custom bundles", async (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { @@ -686,7 +689,8 @@ test("Standard build: nulled taskFunction to skip tasks", (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" - } + }, + supportsDifferentialUpdates: true, }, replaceVersion: { options: { diff --git a/packages/project/test/lib/specifications/types/Application.js b/packages/project/test/lib/specifications/types/Application.js index 0a53ae309b0..cf27337910d 100644 --- a/packages/project/test/lib/specifications/types/Application.js +++ b/packages/project/test/lib/specifications/types/Application.js @@ -357,7 +357,8 @@ test("Read and write resources outside of app namespace", async (t) => { const workspace = project.getWorkspace(); await workspace.write(createResource({ - path: "/resources/my-custom-bundle.js" + path: "/resources/my-custom-bundle.js", + string: "// some custom bundle content" })); const buildtimeReader = project.getReader({style: "buildtime"}); diff --git a/packages/project/test/lib/specifications/types/Component.js b/packages/project/test/lib/specifications/types/Component.js index f5713be9d95..25bac64d823 100644 --- a/packages/project/test/lib/specifications/types/Component.js +++ b/packages/project/test/lib/specifications/types/Component.js @@ -358,7 +358,8 @@ test("Read and write resources outside of app namespace", async (t) => { const workspace = project.getWorkspace(); await workspace.write(createResource({ - path: "/resources/my-custom-bundle.js" + path: "/resources/my-custom-bundle.js", + string: "// some custom bundle content" })); const buildtimeReader = project.getReader({style: "buildtime"}); From 1c3b431c3fd6d52e9ac55e40da8f143b24560e37 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 27 Jan 2026 11:11:17 +0100 Subject: [PATCH 135/223] refactor(project): Move dependency indice init into PBC --- .../lib/build/cache/ProjectBuildCache.js | 36 ++++++++++--------- .../lib/build/helpers/ProjectBuildContext.js | 9 ----- .../test/lib/build/cache/ProjectBuildCache.js | 4 +-- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 7c6c0c1dc84..b1c29273668 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -54,6 +54,7 @@ export default class ProjectBuildCache { #writtenResultResourcePaths = []; #cacheState = CACHE_STATES.INITIALIZING; + #dependencyIndicesInitialized = false; /** * Creates a new ProjectBuildCache instance @@ -95,20 +96,6 @@ export default class ProjectBuildCache { return cache; } - async refreshDependencyIndices(dependencyReader) { - if (this.#cacheState === CACHE_STATES.EMPTY) { - // No need to update indices for empty cache - return false; - } - const updateStart = performance.now(); - await this.#refreshDependencyIndices(dependencyReader); - if (log.isLevelEnabled("perf")) { - log.perf( - `Refreshed dependency indices for project ${this.#project.getName()} ` + - `in ${(performance.now() - updateStart).toFixed(2)} ms`); - } - } - /** * Sets the dependency reader for accessing dependency resources * @@ -125,6 +112,17 @@ export default class ProjectBuildCache { this.#currentProjectReader = this.#project.getReader(); this.#currentDependencyReader = dependencyReader; + if (!this.#dependencyIndicesInitialized) { + const updateStart = performance.now(); + await this._initDependencyIndices(dependencyReader); + if (log.isLevelEnabled("perf")) { + log.perf( + `Initialized dependency indices for project ${this.#project.getName()} ` + + `in ${(performance.now() - updateStart).toFixed(2)} ms`); + } + this.#dependencyIndicesInitialized = true; + } + if (this.#cacheState === CACHE_STATES.INITIALIZING) { throw new Error(`Project ${this.#project.getName()} build cache unexpectedly not yet initialized.`); } @@ -189,12 +187,18 @@ export default class ProjectBuildCache { } /** - * Refresh dependency indices for all tasks + * Initialize dependency indices for all tasks. This only needs to be called once per build. + * Later builds of the same project during the same overall build can reuse the existing indices + * (they will be updated based on input via dependencyResourcesChanged) * * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources * @returns {Promise} */ - async #refreshDependencyIndices(dependencyReader) { + async _initDependencyIndices(dependencyReader) { + if (this.#cacheState === CACHE_STATES.EMPTY) { + // No need to update indices for empty cache + return false; + } let depIndicesChanged = false; await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { const changed = await taskCache.refreshDependencyIndices(dependencyReader); diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 35e68f7fa36..f9b3f3d39b2 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -12,8 +12,6 @@ import ProjectBuildCache from "../cache/ProjectBuildCache.js"; * @memberof @ui5/project/build/helpers */ class ProjectBuildContext { - #initialPrepareRun = true; - /** * Creates a new ProjectBuildContext instance * @@ -268,13 +266,6 @@ class ProjectBuildContext { await this.getTaskRunner().getRequiredDependencies(), true, // Force creation of new reader since project readers might have changed during their (re-)build ); - if (this.#initialPrepareRun) { - this.#initialPrepareRun = false; - // If this is the first build of the project, the dependency indices must be refreshed - // Later builds of the same project during the same overall build can reuse the existing indices - // (they will be updated based on input via #dependencyResourcesChanged) - await this.getBuildCache().refreshDependencyIndices(depReader); - } const boolOrChangedPaths = await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader); if (Array.isArray(boolOrChangedPaths)) { // Cache can be used, but some resources have changed diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 79e277ef98d..013f820d67c 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -436,7 +436,7 @@ test("prepareProjectBuildAndValidateCache: returns false for empty cache", async t.is(result, false, "Returns false for empty cache"); }); -test("refreshDependencyIndices: updates dependency indices", async (t) => { +test("_initDependencyIndices: updates dependency indices", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); @@ -504,7 +504,7 @@ test("refreshDependencyIndices: updates dependency indices", async (t) => { byPath: sinon.stub().resolves(null) }; - await cache.refreshDependencyIndices(mockDependencyReader); + await cache._initDependencyIndices(mockDependencyReader); t.pass("Dependency indices refreshed"); }); From 52fa057d95d9bf155b8dab6ad12ca104eff9b20c Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Tue, 27 Jan 2026 16:29:46 +0100 Subject: [PATCH 136/223] fix(project): Improve BuildServer stability on resource changes --- packages/project/lib/build/BuildServer.js | 83 +++++++++----- packages/project/lib/build/ProjectBuilder.js | 2 +- .../project/lib/build/helpers/WatchHandler.js | 107 +++++++----------- .../test/lib/build/BuildServer.integration.js | 14 ++- 4 files changed, 100 insertions(+), 106 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 547341c99e4..ebeb9744e78 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -38,6 +38,7 @@ class BuildServer extends EventEmitter { #projectBuilder; #watchHandler; #rootProjectName; + #resourceChangeQueue = new Map(); #projectBuildStatus = new Map(); #pendingBuildRequest = new Set(); #activeBuild = null; @@ -115,20 +116,9 @@ class BuildServer extends EventEmitter { watchHandler.on("error", (err) => { this.emit("error", err); }); - watchHandler.on("change", (eventType, filePath, project) => { - log.verbose(`Source change detected: ${eventType} ${filePath} in project '${project.getName()}'`); - this.#projectResourceChangedLive(project, ["add", "unlink", "unlinkDir"].includes(eventType)); - }); - watchHandler.on("batchedChanges", (changes) => { - log.verbose(`Received batched source changes for projects: ${[...changes.keys()].join(", ")}`); - if (this.#activeBuild) { - log.verbose("Waiting for active build to finish before processing batched source changes"); - this.#activeBuild.finally(() => { - this.#batchResourceChanges(changes); - }); - } else { - this.#batchResourceChanges(changes); - } + watchHandler.on("change", (eventType, resourcePath, project) => { + log.verbose(`Source change detected: ${eventType} ${resourcePath} in project '${project.getName()}'`); + this._projectResourceChanged(project, resourcePath, ["add", "unlink", "unlinkDir"].includes(eventType)); }); } @@ -257,33 +247,47 @@ class BuildServer extends EventEmitter { * we abort all active builds affecting the changed project or any of its dependents. * * @param {@ui5/project/specifications/Project} project Project where the resource change occurred + * @param {string} filePath Path of the affected file * @param {boolean} fileAddedOrRemoved Whether a file was added or removed */ - #projectResourceChangedLive(project, fileAddedOrRemoved) { + _projectResourceChanged(project, filePath, fileAddedOrRemoved) { + // First, invalidate all potentially affected projects (which also aborts any running builds) for (const {project: affectedProject} of this.#graph.traverseDependents(project.getName(), true)) { const projectBuildStatus = this.#projectBuildStatus.get(affectedProject.getName()); - projectBuildStatus.abortBuild(new AbortBuildError(`Source change in project '${project.getName()}'`)); + projectBuildStatus.invalidate(`Source change in project '${project.getName()}'`); if (fileAddedOrRemoved) { // Reset any cached readers in case files were added or removed projectBuildStatus.resetReaderCache(); } } - } - #batchResourceChanges(changes) { - // Inform project builder - const affectedProjects = this.#projectBuilder.resourcesChanged(changes); + // Enqueue resource change for processing before next build + const queuedChanges = this.#resourceChangeQueue.get(project.getName()); + if (queuedChanges) { + queuedChanges.add(filePath); + } else { + this.#resourceChangeQueue.set(project.getName(), new Set([filePath])); + } - for (const projectName of affectedProjects) { - log.verbose(`Invalidating built project '${projectName}' due to source changes`); - const projectBuildStatus = this.#projectBuildStatus.get(projectName); - projectBuildStatus.invalidate(); + // : Emit event debounced + // Emit change event immediately so that consumers can react to it (like browser reloading) + // const changedResourcePaths = [...changes.values()].flat(); + // this.emit("sourcesChanged", changedResourcePaths); + } + + #flushResourceChanges() { + if (this.#resourceChangeQueue.size === 0) { + return; } - this.#triggerRequestQueue(); + const changes = this.#resourceChangeQueue; + this.#resourceChangeQueue = new Map(); - const changedResourcePaths = [...changes.values()].flat(); - this.emit("sourcesChanged", changedResourcePaths); + // Inform project builder + // This is essential so that the project builder can determine changed resources as it does not + // use file watchers or check for all changed files by itself + this.#projectBuilder.resourcesChanged(changes); } + /** * Enqueues a project for building and returns a promise that resolves with its reader * @@ -351,6 +355,9 @@ class BuildServer extends EventEmitter { return this.#projectBuildStatus.get(projectName).getAbortSignal(); })); + // Process any queued resource changes (must be done before starting the build) + this.#flushResourceChanges(); + // Set active build to prevent concurrent builds const buildPromise = this.#activeBuild = this.#projectBuilder.build({ includeRootProject: buildRootProject, @@ -363,11 +370,13 @@ class BuildServer extends EventEmitter { }).catch((err) => { if (err instanceof AbortBuildError) { log.info("Build aborted"); + log.verbose(`Projects affected by abort: ${projectsToBuild.join(", ")}`); // Build was aborted - do not log as error // Re-queue any outstanding projects for (const projectName of projectsToBuild) { const projectBuildStatus = this.#projectBuildStatus.get(projectName); if (!projectBuildStatus.isFresh()) { + log.verbose(`Re-enqueueing project '${projectName}' after aborted build`); this.#pendingBuildRequest.add(projectName); } } @@ -376,9 +385,11 @@ class BuildServer extends EventEmitter { // Build failed - reject promises for projects that weren't built for (const projectName of projectsToBuild) { const projectBuildStatus = this.#projectBuildStatus.get(projectName); - projectBuildStatus.rejectReaderRequestes(err); + projectBuildStatus.rejectReaderRequests(err); } // Re-throw to be handled by caller + // TODO: rather emit 'error' event for the BuildServer and continue processing the queue? + // Currently, this.#activeBuild will not be cleared. throw err; } }); @@ -389,6 +400,11 @@ class BuildServer extends EventEmitter { this.#activeBuild = null; if (signal.aborted) { log.verbose(`Build aborted for projects: ${projectsToBuild.join(", ")}`); + // Do not continue processing the queue if the build was aborted, but re-trigger processing debounced + // to ensure that any source changes are properly queued before the next build. + // This is also essential to re-trigger the build in case all resources changes have already been + // processed while the build was still aborting. Otherwise the build would not be re-triggered. + this.#triggerRequestQueue(); return; } } @@ -398,6 +414,7 @@ class BuildServer extends EventEmitter { const PROJECT_STATES = Object.freeze({ INITIAL: "initial", INVALIDATED: "invalidated", + // TODO: New state BUILDING FRESH: "fresh", }); @@ -407,10 +424,14 @@ class ProjectBuildStatus { #reader; #abortController = new AbortController(); - invalidate() { + invalidate(reason = "Project invalidated") { + if (this.#state === PROJECT_STATES.INVALIDATED) { + // Already invalidated + return; + } this.#state = PROJECT_STATES.INVALIDATED; // Ensure any running build is aborted. Then reset the abort controller - this.#abortController.abort(new AbortBuildError("Project invalidated")); + this.#abortController.abort(new AbortBuildError(reason)); this.#abortController = new AbortController(); } @@ -448,7 +469,7 @@ class ProjectBuildStatus { this.#readerQueue.push(promiseResolvers); } - rejectReaderRequestes(error) { + rejectReaderRequests(error) { this.#state = PROJECT_STATES.INVALIDATED; for (const {reject} of this.#readerQueue) { reject(error); diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 06050548e3b..a2bda654d89 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -125,7 +125,7 @@ class ProjectBuilder { * * @public * @param {Array} changes Array of resource changes to propagate - * @returns {Promise} Promise resolving when changes have been propagated + * @returns {Set} Names of projects potentially affected by the resource changes * @throws {Error} If a build is currently running */ resourcesChanged(changes) { diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 23cd8d56133..08d5d6d2ffd 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -11,9 +11,6 @@ const log = getLogger("build:helpers:WatchHandler"); */ class WatchHandler extends EventEmitter { #closeCallbacks = []; - #sourceChanges = new Map(); - #ready = false; - #fileChangeHandlerTimeout; constructor() { super(); @@ -22,35 +19,46 @@ class WatchHandler extends EventEmitter { async watch(projects) { const readyPromises = []; for (const project of projects) { - const paths = project.getSourcePaths(); - log.verbose(`Watching source paths: ${paths.join(", ")}`); + readyPromises.push(this._watchProject(project)); + } + await Promise.all(readyPromises); + } - const watcher = chokidar.watch(paths, { - ignoreInitial: true, - }); - this.#closeCallbacks.push(async () => { - await watcher.close(); - }); - watcher.on("all", (event, filePath) => { - if (event === "addDir") { - // Ignore directory creation events - return; - } - this.#handleWatchEvents(event, filePath, project).catch((err) => { - this.emit("error", err); - }); - }); - const {promise, resolve: ready} = Promise.withResolvers(); - readyPromises.push(promise); - watcher.on("ready", () => { - this.#ready = true; - ready(); - }); - watcher.on("error", (err) => { + async _watchProject(project) { + let ready = false; + const paths = project.getSourcePaths(); + log.verbose(`Watching source paths: ${paths.join(", ")}`); + + const watcher = chokidar.watch(paths, { + ignoreInitial: true, + }); + this.#closeCallbacks.push(async () => { + await watcher.close(); + }); + watcher.on("all", (event, filePath) => { + if (!ready) { + // Ignore events before ready + return; + } + if (event === "addDir") { + // Ignore directory creation events + return; + } + this.#handleWatchEvents(event, filePath, project).catch((err) => { this.emit("error", err); }); - } - return await Promise.all(readyPromises); + }); + const {promise, resolve} = Promise.withResolvers(); + + watcher.on("ready", () => { + ready = true; + resolve(); + }); + watcher.on("error", (err) => { + this.emit("error", err); + }); + + return promise; } async destroy() { @@ -60,46 +68,9 @@ class WatchHandler extends EventEmitter { } async #handleWatchEvents(eventType, filePath, project) { - log.verbose(`File changed: ${eventType} ${filePath}`); - await this.#fileChanged(project, filePath); - this.emit("change", eventType, filePath, project); - } - - #fileChanged(project, filePath) { - // Collect changes (grouped by project), then trigger callbacks const resourcePath = project.getVirtualPath(filePath); - const projectName = project.getName(); - if (!this.#sourceChanges.has(projectName)) { - this.#sourceChanges.set(projectName, new Set()); - } - this.#sourceChanges.get(projectName).add(resourcePath); - - this.#processQueue(); - } - - #processQueue() { - if (!this.#ready || !this.#sourceChanges.size) { - // Prevent premature processing - return; - } - - // Trigger change event debounced - if (this.#fileChangeHandlerTimeout) { - clearTimeout(this.#fileChangeHandlerTimeout); - } - this.#fileChangeHandlerTimeout = setTimeout(async () => { - this.#fileChangeHandlerTimeout = null; - - const sourceChanges = this.#sourceChanges; - // Reset file changes - this.#sourceChanges = new Map(); - - try { - this.emit("batchedChanges", sourceChanges); - } catch (err) { - this.emit("error", err); - } - }, 100); + log.verbose(`File changed: ${eventType} ${filePath} (as ${resourcePath} in project '${project.getName()}')`); + this.emit("change", eventType, resourcePath, project); } } diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 88df8a8292b..f23e00f90d5 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -193,6 +193,8 @@ class FixtureTester { // Public this.fixturePath = getTmpPath(fixtureName); + this.buildServer = null; + this.graph = null; } async _initialize() { @@ -206,25 +208,25 @@ class FixtureTester { } async teardown() { - if (this._buildServer) { - await this._buildServer.destroy(); + if (this.buildServer) { + await this.buildServer.destroy(); } } async serveProject({graphConfig = {}, config = {}} = {}) { await this._initialize(); - const graph = await graphFromPackageDependencies({ + const graph = this.graph = await graphFromPackageDependencies({ ...graphConfig, cwd: this.fixturePath, }); // Execute the build - this._buildServer = await graph.serve(config); - this._buildServer.on("error", (err) => { + this.buildServer = await graph.serve(config); + this.buildServer.on("error", (err) => { this._t.fail(`Build server error: ${err.message}`); }); - this._reader = this._buildServer.getReader(); + this._reader = this.buildServer.getReader(); } async requestResource(resource, assertions = {}) { From 4c1f228299062377ecfd90d2ffe62cf1b7e2b85f Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 27 Jan 2026 18:07:04 +0100 Subject: [PATCH 137/223] test(project): Add case for BuildServer which requests application and library resources `+` Adjust FixtureTester(BuildServer.integration.js) --- .../test/lib/build/BuildServer.integration.js | 174 ++++++++++++++---- 1 file changed, 140 insertions(+), 34 deletions(-) diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index f23e00f90d5..5bc6660bd99 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -50,9 +50,12 @@ test.serial("Serve application.a, initial file changes", async (t) => { await fs.appendFile(changedFilePath, `\ntest("initial change");\n`); // Request the changed resource immediately - const resourceRequestPromise = fixtureTester.requestResource("/test.js", { - projects: { - "application.a": {} + const resourceRequestPromise = fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } } }); // Directly change the source file again, which should abort the current build and trigger a new one @@ -74,15 +77,21 @@ test.serial("Serve application.a, request application resource", async (t) => { // #1 request with empty cache await fixtureTester.serveProject(); - await fixtureTester.requestResource("/test.js", { - projects: { - "application.a": {} + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } } }); // #2 request with cache - await fixtureTester.requestResource("/test.js", { - projects: {} + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: {} + } }); // Change a source file in application.a @@ -92,16 +101,19 @@ test.serial("Serve application.a, request application resource", async (t) => { await setTimeout(500); // Wait for the file watcher to detect and propagate the change // #3 request with cache and changes - const res = await fixtureTester.requestResource("/test.js", { - projects: { - "application.a": { - skippedTasks: [ - "escapeNonAsciiCharacters", - // Note: replaceCopyright is skipped because no copyright is configured in the project - "replaceCopyright", - "enhanceManifest", - "generateFlexChangesBundle", - ] + const res = await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } } } }); @@ -116,15 +128,21 @@ test.serial("Serve application.a, request library resource", async (t) => { // #1 request with empty cache await fixtureTester.serveProject(); - await fixtureTester.requestResource("/resources/library/a/.library", { - projects: { - "library.a": {} + await fixtureTester.requestResource({ + resource: "/resources/library/a/.library", + assertions: { + projects: { + "library.a": {} + } } }); // #2 request with cache - await fixtureTester.requestResource("/resources/library/a/.library", { - projects: {} + await fixtureTester.requestResource({ + resource: "/resources/library/a/.library", + assertions: { + projects: {} + } }); // Change a source file in library.a @@ -140,14 +158,17 @@ test.serial("Serve application.a, request library resource", async (t) => { await setTimeout(500); // Wait for the file watcher to detect and propagate the change // #3 request with cache and changes - const dotLibraryResource = await fixtureTester.requestResource("/resources/library/a/.library", { - projects: { - "library.a": { - skippedTasks: [ - "escapeNonAsciiCharacters", - "minify", - "replaceBuildtime", - ] + const dotLibraryResource = await fixtureTester.requestResource({ + resource: "/resources/library/a/.library", + assertions: { + projects: { + "library.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "minify", + "replaceBuildtime", + ] + } } } }); @@ -160,8 +181,11 @@ test.serial("Serve application.a, request library resource", async (t) => { ); // #4 request with cache (no changes) - const manifestResource = await fixtureTester.requestResource("/resources/library/a/manifest.json", { - projects: {} + const manifestResource = await fixtureTester.requestResource({ + resource: "/resources/library/a/manifest.json", + assertions: { + projects: {} + } }); // Check whether the manifest is served correctly with changed .library content reflected @@ -172,6 +196,78 @@ test.serial("Serve application.a, request library resource", async (t) => { ); }); +test.serial("Serve application.a, request application resource AND library resource", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + // #1 request with empty cache + await fixtureTester.serveProject(); + await fixtureTester.requestResources({ + resources: ["/test.js", "/resources/library/a/.library"], + assertions: { + projects: { + "library.a": {}, + "application.a": {} + } + } + }); + + // #2 request with cache + await fixtureTester.requestResources({ + resources: ["/test.js", "/resources/library/a/.library"], + assertions: { + projects: {} + } + }); + + // Change a source file in application.a and library.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added");\n`); + const changedFilePath2 = `${fixtureTester.fixturePath}/node_modules/collection/library.a/src/library/a/.library`; + await fs.writeFile( + changedFilePath2, + (await fs.readFile(changedFilePath2, {encoding: "utf8"})).replace( + `Library A`, + `Library A (updated #1)` + ) + ); + + await setTimeout(500); // Wait for the file watcher to detect and propagate the changes + + // #3 request with cache and changes + const [resource1, resource2] = await fixtureTester.requestResources({ + resources: ["/test.js", "/resources/library/a/.library"], + assertions: { + projects: { + "library.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "minify", + "replaceBuildtime", + ] + }, + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Check whether the changed files contain the correct contents + const resource1FileContent = await resource1.getString(); + const resource2FileContent = await resource2.getString(); + t.true(resource1FileContent.includes(`test("line added");`), "Resource contains changed file content"); + t.true( + resource2FileContent.includes(`Library A (updated #1)`), + "Resource contains changed file content" + ); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } @@ -229,7 +325,7 @@ class FixtureTester { this._reader = this.buildServer.getReader(); } - async requestResource(resource, assertions = {}) { + async requestResource({resource, assertions = {}}) { this._sinon.resetHistory(); const res = await this._reader.byPath(resource); // Apply assertions if provided @@ -239,6 +335,16 @@ class FixtureTester { return res; } + async requestResources({resources, assertions = {}}) { + this._sinon.resetHistory(); + const returnedResources = await Promise.all(resources.map((resource) => this._reader.byPath(resource))); + // Apply assertions if provided + if (assertions) { + this._assertBuild(assertions); + } + return returnedResources; + } + _assertBuild(assertions) { const {projects = {}} = assertions; const eventArgs = this._t.context.projectBuildStatusEventStub.args.map((args) => args[0]); From 0ac859830eb8accbb86b7f443db19e642fa4c088 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 28 Jan 2026 17:26:40 +0100 Subject: [PATCH 138/223] test(project): Add cases `+` Add cases for OmitFromBuildResult tagging `+` Add cases for components (copied "node_modules" folder from application.a) `+` Adjust tests for BuildContext.js to work with new logic --- .../fixtures/application.a/task.example.js | 11 +- .../collection/library.a/package.json | 17 ++ .../library.a/src/library/a/.library | 17 ++ .../library/a/themes/base/library.source.less | 6 + .../library.a/test/library/a/Test.html | 0 .../collection/library.a/ui5.yaml | 5 + .../collection/library.b/package.json | 9 ++ .../library.b/src/library/b/.library | 17 ++ .../library.b/test/library/b/Test.html | 0 .../collection/library.b/ui5.yaml | 5 + .../collection/library.c/package.json | 9 ++ .../library.c/src/library/c/.library | 17 ++ .../library.c/test/LibraryC/Test.html | 0 .../collection/library.c/ui5.yaml | 5 + .../node_modules/library.d/package.json | 9 ++ .../library.d/src/library/d/.library | 11 ++ .../library.d/test/library/d/Test.html | 0 .../node_modules/library.d/ui5.yaml | 10 ++ .../node_modules/collection/package.json | 18 +++ .../node_modules/collection/ui5.yaml | 12 ++ .../library.d/main/src/library/d/.library | 11 ++ .../library.d/main/src/library/d/some.js | 4 + .../library.d/main/test/library/d/Test.html | 0 .../node_modules/library.d/package.json | 9 ++ .../node_modules/library.d/ui5.yaml | 10 ++ .../lib/build/ProjectBuilder.integration.js | 146 ++++++++++++++++++ .../test/lib/build/helpers/BuildContext.js | 83 +++++++--- 27 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.a/package.json create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/.library create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/themes/base/library.source.less create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.a/test/library/a/Test.html create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.a/ui5.yaml create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.b/package.json create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.b/src/library/b/.library create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.b/test/library/b/Test.html create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.b/ui5.yaml create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.c/package.json create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.c/src/library/c/.library create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.c/test/LibraryC/Test.html create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/library.c/ui5.yaml create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/package.json create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/src/library/d/.library create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/ui5.yaml create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/package.json create mode 100644 packages/project/test/fixtures/component.a/node_modules/collection/ui5.yaml create mode 100644 packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/.library create mode 100644 packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/some.js create mode 100644 packages/project/test/fixtures/component.a/node_modules/library.d/main/test/library/d/Test.html create mode 100644 packages/project/test/fixtures/component.a/node_modules/library.d/package.json create mode 100644 packages/project/test/fixtures/component.a/node_modules/library.d/ui5.yaml diff --git a/packages/project/test/fixtures/application.a/task.example.js b/packages/project/test/fixtures/application.a/task.example.js index 600405554f4..efc4d0f12d9 100644 --- a/packages/project/test/fixtures/application.a/task.example.js +++ b/packages/project/test/fixtures/application.a/task.example.js @@ -1,3 +1,12 @@ -module.exports = function () { +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { console.log("Example task executed"); + + // Omit a specific resource from the build result + const omittedResource = await workspace.byPath(`/resources/${projectNamespace}/fileToBeOmitted.js`); + if (omittedResource) { + taskUtil.setTag(omittedResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + }; }; diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.a/package.json b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/package.json new file mode 100644 index 00000000000..2179673d41d --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/package.json @@ -0,0 +1,17 @@ +{ + "name": "library.a", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "ui5": { + "name": "library.a", + "type": "library", + "settings": { + "src": "src", + "test": "test" + } + } +} diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/.library b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/.library new file mode 100644 index 00000000000..25c8603f31a --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/.library @@ -0,0 +1,17 @@ + + + + library.a + SAP SE + ${copyright} + ${version} + + Library A + + + + library.d + + + + diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/themes/base/library.source.less b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/themes/base/library.source.less new file mode 100644 index 00000000000..ff0f1d5e3df --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/src/library/a/themes/base/library.source.less @@ -0,0 +1,6 @@ +@libraryAColor1: lightgoldenrodyellow; + +.library-a-foo { + color: @libraryAColor1; + padding: 1px 2px 3px 4px; +} diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.a/test/library/a/Test.html b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/test/library/a/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.a/ui5.yaml b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/ui5.yaml new file mode 100644 index 00000000000..8d4784313c3 --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.a/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.a diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.b/package.json b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/package.json new file mode 100644 index 00000000000..2a0243b1683 --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.b", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.b/src/library/b/.library b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/src/library/b/.library new file mode 100644 index 00000000000..36052acebdc --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/src/library/b/.library @@ -0,0 +1,17 @@ + + + + library.b + SAP SE + ${copyright} + ${version} + + Library B + + + + library.d + + + + diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.b/test/library/b/Test.html b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/test/library/b/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.b/ui5.yaml b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/ui5.yaml new file mode 100644 index 00000000000..b2fe5be59ee --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.b/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.b diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.c/package.json b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/package.json new file mode 100644 index 00000000000..64ac75d6ffe --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.c", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.c/src/library/c/.library b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/src/library/c/.library new file mode 100644 index 00000000000..4180ce2af2f --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/src/library/c/.library @@ -0,0 +1,17 @@ + + + + library.c + SAP SE + ${copyright} + ${version} + + Library C + + + + library.d + + + + diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.c/test/LibraryC/Test.html b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/test/LibraryC/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/library.c/ui5.yaml b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/ui5.yaml new file mode 100644 index 00000000000..7c5e38a7fc1 --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/library.c/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.c diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/package.json b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/package.json new file mode 100644 index 00000000000..90c75040abe --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.d", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/src/library/d/.library b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/src/library/d/.library new file mode 100644 index 00000000000..21251d1bbba --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + ${copyright} + ${version} + + Library D + + diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/ui5.yaml new file mode 100644 index 00000000000..a47c1f64c3d --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/node_modules/library.d/ui5.yaml @@ -0,0 +1,10 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.d +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/package.json b/packages/project/test/fixtures/component.a/node_modules/collection/package.json new file mode 100644 index 00000000000..81b948438bd --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/package.json @@ -0,0 +1,18 @@ +{ + "name": "collection", + "version": "1.0.0", + "description": "Simple Collection", + "dependencies": { + "library.d": "file:../library.d" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "collection": { + "modules": { + "library.a": "./library.a", + "library.b": "./library.b", + "library.c": "./library.c" + } + } +} diff --git a/packages/project/test/fixtures/component.a/node_modules/collection/ui5.yaml b/packages/project/test/fixtures/component.a/node_modules/collection/ui5.yaml new file mode 100644 index 00000000000..e47048de6a7 --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/collection/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "2.1" +metadata: + name: application.a.collection.dependency.shim +kind: extension +type: project-shim +shims: + collections: + collection: + modules: + "library.a": "./library.a" + "library.b": "./library.b" + "library.c": "./library.c" \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/.library b/packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/.library new file mode 100644 index 00000000000..53c2d14c9d6 --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + Some fancy copyright + ${version} + + Library D + + diff --git a/packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/some.js b/packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/some.js new file mode 100644 index 00000000000..81e73436075 --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/library.d/main/src/library/d/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/node_modules/library.d/main/test/library/d/Test.html b/packages/project/test/fixtures/component.a/node_modules/library.d/main/test/library/d/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/component.a/node_modules/library.d/package.json b/packages/project/test/fixtures/component.a/node_modules/library.d/package.json new file mode 100644 index 00000000000..90c75040abe --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/library.d/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.d", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/component.a/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/component.a/node_modules/library.d/ui5.yaml new file mode 100644 index 00000000000..a47c1f64c3d --- /dev/null +++ b/packages/project/test/fixtures/component.a/node_modules/library.d/ui5.yaml @@ -0,0 +1,10 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.d +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 4d3c73293fd..edc11bf9eed 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -179,6 +179,73 @@ test.serial("Build application.a project multiple times", async (t) => { }); }); +// eslint-disable-next-line ava/no-skip-test -- tag handling to be implemented +test.serial.skip("Build application.a (custom task and tag handling)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 build (no cache, no changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Create new file which should get tagged as "OmitFromBuildResult" by a custom task + await fs.writeFile(`${fixtureTester.fixturePath}/webapp/fileToBeOmitted.js`, + `console.log("this file should be ommited in the build result")`); + + // #2 build (with cache, with changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Check that fileToBeOmitted.js is not in dist + await t.throwsAsync(fs.readFile(`${destPath}/fileToBeOmitted.js`, {encoding: "utf8"})); + + // #3 build (with cache, no changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // Check that fileToBeOmitted.js is not in dist again --> FIXME: Currently failing here + await t.throwsAsync(fs.readFile(`${destPath}/fileToBeOmitted.js`, {encoding: "utf8"})); + + // Delete the file again + await fs.rm(`${fixtureTester.fixturePath}/webapp/fileToBeOmitted.js`); + + // #4 build (with cache, with changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} // -> everything should be skipped (due to very first build) + } + }); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; @@ -401,6 +468,85 @@ test.serial("Build theme.library.e project multiple times", async (t) => { }); }); +test.serial("Build component.a project multiple times", async (t) => { + const fixtureTester = new FixtureTester(t, "component.a"); + const destPath = fixtureTester.destPath; + + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "component.a": {} + } + } + }); + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // Change a source file in component.a + const changedFilePath = `${fixtureTester.fixturePath}/src/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added");\n`); + + // #3 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "component.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + // Note: replaceCopyright is skipped because no copyright is configured in the project + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Check whether the changed file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/resources/id1/test.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); + + // #4 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + } + } + }); + + // #5 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // #6 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } diff --git a/packages/project/test/lib/build/helpers/BuildContext.js b/packages/project/test/lib/build/helpers/BuildContext.js index cc09a7cd870..7cfbfea5ed8 100644 --- a/packages/project/test/lib/build/helpers/BuildContext.js +++ b/packages/project/test/lib/build/helpers/BuildContext.js @@ -1,14 +1,29 @@ import test from "ava"; import sinon from "sinon"; +import esmock from "esmock"; import OutputStyleEnum from "../../../../lib/build/helpers/ProjectBuilderOutputStyle.js"; +test.beforeEach(async (t) => { + t.context.ProjectBuildContextCreateStub = sinon.stub().callsFake(async () => { + return {}; // Explicitly returning empty object to show uniqueness + }); + t.context.CacheManagerCreate = sinon.stub().returns({}); + t.context.BuildContext = await esmock("../../../../lib/build/helpers/BuildContext.js", { + "../../../../lib/build/helpers/ProjectBuildContext.js": { + create: t.context.ProjectBuildContextCreateStub + }, + "../../../../lib/build/cache/CacheManager.js": { + create: t.context.CacheManagerCreate + } + }); +}); + test.afterEach.always((t) => { sinon.restore(); }); -import BuildContext from "../../../../lib/build/helpers/BuildContext.js"; - test("Missing parameters", (t) => { + const {BuildContext} = t.context; const error1 = t.throws(() => { new BuildContext(); }); @@ -23,6 +38,8 @@ test("Missing parameters", (t) => { }); test("getRootProject", (t) => { + const {BuildContext} = t.context; + const rootProjectStub = sinon.stub() .onFirstCall().returns({getType: () => "library"}) .returns("pony"); @@ -33,6 +50,8 @@ test("getRootProject", (t) => { }); test("getGraph", (t) => { + const {BuildContext} = t.context; + const graph = { getRoot: () => ({getType: () => "library"}), }; @@ -42,6 +61,8 @@ test("getGraph", (t) => { }); test("getTaskRepository", (t) => { + const {BuildContext} = t.context; + const graph = { getRoot: () => ({getType: () => "library"}), }; @@ -51,6 +72,8 @@ test("getTaskRepository", (t) => { }); test("getBuildConfig: Default values", (t) => { + const {BuildContext} = t.context; + const graph = { getRoot: () => ({getType: () => "library"}), }; @@ -68,6 +91,8 @@ test("getBuildConfig: Default values", (t) => { }); test("getBuildConfig: Custom values", (t) => { + const {BuildContext} = t.context; + const buildContext = new BuildContext({ getRoot: () => { return { @@ -96,6 +121,8 @@ test("getBuildConfig: Custom values", (t) => { }); test("createBuildManifest not supported for type application", (t) => { + const {BuildContext} = t.context; + const err = t.throws(() => { new BuildContext({ getRoot: () => { @@ -113,6 +140,8 @@ test("createBuildManifest not supported for type application", (t) => { }); test("createBuildManifest not supported for type module", (t) => { + const {BuildContext} = t.context; + const err = t.throws(() => { new BuildContext({ getRoot: () => { @@ -130,6 +159,8 @@ test("createBuildManifest not supported for type module", (t) => { }); test("createBuildManifest not supported for self-contained build", (t) => { + const {BuildContext} = t.context; + const err = t.throws(() => { new BuildContext({ getRoot: () => { @@ -148,6 +179,8 @@ test("createBuildManifest not supported for self-contained build", (t) => { }); test("createBuildManifest supported for css-variables build", (t) => { + const {BuildContext} = t.context; + t.notThrows(() => { new BuildContext({ getRoot: () => { @@ -163,6 +196,8 @@ test("createBuildManifest supported for css-variables build", (t) => { }); test("createBuildManifest supported for jsdoc build", (t) => { + const {BuildContext} = t.context; + t.notThrows(() => { new BuildContext({ getRoot: () => { @@ -178,6 +213,8 @@ test("createBuildManifest supported for jsdoc build", (t) => { }); test("outputStyle='Namespace' supported for type application", (t) => { + const {BuildContext} = t.context; + t.notThrows(() => { new BuildContext({ getRoot: () => { @@ -192,6 +229,8 @@ test("outputStyle='Namespace' supported for type application", (t) => { }); test("outputStyle='Flat' not supported for type theme-library", (t) => { + const {BuildContext} = t.context; + const err = t.throws(() => { new BuildContext({ getRoot: () => { @@ -210,6 +249,8 @@ test("outputStyle='Flat' not supported for type theme-library", (t) => { }); test("outputStyle='Flat' not supported for type module", (t) => { + const {BuildContext} = t.context; + const err = t.throws(() => { new BuildContext({ getRoot: () => { @@ -228,6 +269,8 @@ test("outputStyle='Flat' not supported for type module", (t) => { }); test("outputStyle='Flat' not supported for createBuildManifest build", (t) => { + const {BuildContext} = t.context; + const err = t.throws(() => { new BuildContext({ getRoot: () => { @@ -246,6 +289,8 @@ test("outputStyle='Flat' not supported for createBuildManifest build", (t) => { }); test("getOption", (t) => { + const {BuildContext} = t.context; + const graph = { getRoot: () => ({getType: () => "library"}), }; @@ -260,23 +305,25 @@ test("getOption", (t) => { "(not exposed as build option)"); }); -test("createProjectContext", async (t) => { - const graph = { - getRoot: () => ({getType: () => "library"}), - }; +test("getProjectContext", async (t) => { + const {BuildContext} = t.context; + + const rootProjectStub = sinon.stub() + .returns({getType: () => "library", getRootPath: () => ""}); + const graph = {getRoot: rootProjectStub, getProject: () => "project"}; + const buildContext = new BuildContext(graph, "taskRepository"); - const projectBuildContext = await buildContext.createProjectContext({ - project: { - getName: () => "project", - getType: () => "type", - }, - }); + const projectBuildContext = await buildContext.getProjectContext("project"); + t.is(t.context.ProjectBuildContextCreateStub.callCount, 1); - t.deepEqual(buildContext._projectBuildContexts, [projectBuildContext], - "Project build context has been added to internal array"); + const projectBuildContext2 = await buildContext.getProjectContext("project"); + t.is(t.context.ProjectBuildContextCreateStub.callCount, 1); + t.is(projectBuildContext, projectBuildContext2); }); test("executeCleanupTasks", async (t) => { + const {BuildContext} = t.context; + const graph = { getRoot: () => ({getType: () => "library"}), }; @@ -284,12 +331,8 @@ test("executeCleanupTasks", async (t) => { const executeCleanupTasks = sinon.stub().resolves(); - buildContext._projectBuildContexts.push({ - executeCleanupTasks - }); - buildContext._projectBuildContexts.push({ - executeCleanupTasks - }); + buildContext._projectBuildContexts.set("project", {executeCleanupTasks}); + buildContext._projectBuildContexts.set("project2", {executeCleanupTasks}); await buildContext.executeCleanupTasks(); From 13f234093a10d284218846213782bf9eb31e2b6d Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 29 Jan 2026 19:40:20 +0100 Subject: [PATCH 139/223] test(project): Add cases for project type "module" (ProjectBuilder) --- .../test/fixtures/module.b/dev/devTools.js | 1 + .../test/fixtures/module.b/package.json | 5 ++ .../project/test/fixtures/module.b/ui5.yaml | 9 ++ .../lib/build/ProjectBuilder.integration.js | 83 ++++++++++++++++++- 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/project/test/fixtures/module.b/dev/devTools.js create mode 100644 packages/project/test/fixtures/module.b/package.json create mode 100644 packages/project/test/fixtures/module.b/ui5.yaml diff --git a/packages/project/test/fixtures/module.b/dev/devTools.js b/packages/project/test/fixtures/module.b/dev/devTools.js new file mode 100644 index 00000000000..e035bfaeab6 --- /dev/null +++ b/packages/project/test/fixtures/module.b/dev/devTools.js @@ -0,0 +1 @@ +console.log("dev dev dev"); diff --git a/packages/project/test/fixtures/module.b/package.json b/packages/project/test/fixtures/module.b/package.json new file mode 100644 index 00000000000..77806dbb4c6 --- /dev/null +++ b/packages/project/test/fixtures/module.b/package.json @@ -0,0 +1,5 @@ +{ + "name": "module.b", + "version": "1.0.0", + "description": "Custom UI5 module" +} diff --git a/packages/project/test/fixtures/module.b/ui5.yaml b/packages/project/test/fixtures/module.b/ui5.yaml new file mode 100644 index 00000000000..f5365cf1f0b --- /dev/null +++ b/packages/project/test/fixtures/module.b/ui5.yaml @@ -0,0 +1,9 @@ +--- +specVersion: "5.0" +type: module +metadata: + name: module.b +resources: + configuration: + paths: + /resources/b/module/dev/: dev \ No newline at end of file diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index edc11bf9eed..de0dbef6bd0 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -241,7 +241,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, config: {destPath, cleanDest: true}, assertions: { - projects: {} // -> everything should be skipped (due to very first build) + projects: {} // everything should be skipped (already done in very first build) } }); }); @@ -547,6 +547,87 @@ test.serial("Build component.a project multiple times", async (t) => { }); }); +test.serial("Build module.b project multiple times", async (t) => { + const fixtureTester = new FixtureTester(t, "module.b"); + const destPath = fixtureTester.destPath; + + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + }, + }); + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // Add new folder (with files) + await fs.mkdir(`${fixtureTester.fixturePath}/newFolder`, {recursive: true}); + await fs.writeFile(`${fixtureTester.fixturePath}/newFolder/newFile.js`, + `console.log("This is a new file in a new folder.");` + ); + // Update path mapping of ui5.yaml to include new folder + await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, + `--- +specVersion: "5.0" +type: module +metadata: + name: module.b +resources: + configuration: + paths: + /resources/b/module/dev/: dev + /resources/b/module/newFolder/: newFolder` + ); + + // #3 build (no cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + // Check whether the added file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/resources/b/module/newFolder/newFile.js`, + {encoding: "utf8"}); + t.true(builtFileContent.includes(`console.log("This is a new file in a new folder.");`), + "Build dest contains changed file content"); + + // Delete the new folder and its contents again + await fs.rm(`${fixtureTester.fixturePath}/newFolder`, {recursive: true, force: true}); + // Remove the path mapping from ui5.yaml again (Revert to original) + await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, + `--- +specVersion: "5.0" +type: module +metadata: + name: module.b +resources: + configuration: + paths: + /resources/b/module/dev/: dev` + ); + + // #4 build (no cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} // everything should be skipped (already done in very first build) + }, + }); + + // Check that the added file is NOT in the destPath anymore + await t.throwsAsync(fs.readFile(`${destPath}/resources/b/module/newFolder/newFile.js`, {encoding: "utf8"})); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } From 0b84b288a88994e0a19bdf33c522dca6301ff172 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 28 Jan 2026 16:38:29 +0100 Subject: [PATCH 140/223] refactor(project): ProjectBuildCache state handling --- .../project/lib/build/cache/BuildTaskCache.js | 2 +- .../lib/build/cache/ProjectBuildCache.js | 212 +++++++++--------- .../lib/build/cache/ResourceRequestGraph.js | 2 +- .../lib/build/cache/ResourceRequestManager.js | 8 +- .../lib/build/helpers/ProjectBuildContext.js | 2 +- .../test/lib/build/cache/ProjectBuildCache.js | 4 +- .../lib/build/cache/ResourceRequestGraph.js | 4 +- .../lib/build/cache/ResourceRequestManager.js | 3 +- 8 files changed, 122 insertions(+), 115 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index 461e4aee16a..bfb405414d1 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -160,7 +160,7 @@ export default class BuildTaskCache { * * @public * @param {module:@ui5/fs.AbstractReader} dependencyReader Reader for accessing dependency resources - * @returns {Promise} True if any index has changed + * @returns {Promise} */ refreshDependencyIndices(dependencyReader) { return this.#dependencyRequestManager.refreshIndices(dependencyReader); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index b1c29273668..84073c1b5d6 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -11,13 +11,18 @@ import ResourceIndex from "./index/ResourceIndex.js"; import {firstTruthy} from "./utils.js"; const log = getLogger("build:cache:ProjectBuildCache"); -export const CACHE_STATES = Object.freeze({ - INITIALIZING: "initializing", - INITIALIZED: "initialized", - EMPTY: "empty", - REQUIRES_VALIDATION: "requires_validation", +export const INDEX_STATES = Object.freeze({ + RESTORING_PROJECT_INDICES: "restoring_project_indices", + RESTORING_DEPENDENCY_INDICES: "restoring_dependency_indices", + INITIAL: "initial", FRESH: "fresh", - INVALIDATED: "invalidated", + REQUIRES_UPDATE: "requires_update", +}); + +export const RESULT_CACHE_STATES = Object.freeze({ + PENDING_VALIDATION: "pending_validation", + NO_CACHE: "no_cache", + FRESH_AND_IN_USE: "fresh_and_in_use", }); /** @@ -53,8 +58,9 @@ export default class ProjectBuildCache { #changedDependencyResourcePaths = []; #writtenResultResourcePaths = []; - #cacheState = CACHE_STATES.INITIALIZING; - #dependencyIndicesInitialized = false; + #combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES; + #resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; + // #dependencyIndicesInitialized = false; /** * Creates a new ProjectBuildCache instance @@ -104,53 +110,74 @@ export default class ProjectBuildCache { * * @public * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources - * @returns {Promise} - * Undefined if no cache has been found, false if cache is empty, - * or an array of changed resource paths + * @returns {Promise} + * Array of changed resource paths since last build, true if cache is fresh, false + * if cache is empty */ async prepareProjectBuildAndValidateCache(dependencyReader) { this.#currentProjectReader = this.#project.getReader(); this.#currentDependencyReader = dependencyReader; - if (!this.#dependencyIndicesInitialized) { + if (this.#combinedIndexState === INDEX_STATES.INITIAL) { + log.verbose(`Project ${this.#project.getName()} has an empty index cache, skipping change processing.`); + return false; + } + + if (this.#combinedIndexState === INDEX_STATES.RESTORING_DEPENDENCY_INDICES) { const updateStart = performance.now(); - await this._initDependencyIndices(dependencyReader); + await this._refreshDependencyIndices(dependencyReader); if (log.isLevelEnabled("perf")) { log.perf( `Initialized dependency indices for project ${this.#project.getName()} ` + `in ${(performance.now() - updateStart).toFixed(2)} ms`); } - this.#dependencyIndicesInitialized = true; - } + this.#combinedIndexState = INDEX_STATES.FRESH; - if (this.#cacheState === CACHE_STATES.INITIALIZING) { - throw new Error(`Project ${this.#project.getName()} build cache unexpectedly not yet initialized.`); - } - if (this.#cacheState === CACHE_STATES.EMPTY) { - log.verbose(`Project ${this.#project.getName()} has empty cache, skipping change processing.`); - return false; + // After initializing dependency indices, the result cache must be validated + // This should be it's initial state anyways, so we just verify it here + if (this.#resultCacheState !== RESULT_CACHE_STATES.PENDING_VALIDATION) { + throw new Error(`Unexpected result cache state after restoring dependency indices ` + + `for project ${this.#project.getName()}: ${this.#resultCacheState}`); + } } - const flushStart = performance.now(); - await this.#flushPendingChanges(); - if (log.isLevelEnabled("perf")) { - log.perf( - `Flushed pending changes for project ${this.#project.getName()} ` + - `in ${(performance.now() - flushStart).toFixed(2)} ms`); + + if (this.#combinedIndexState === INDEX_STATES.REQUIRES_UPDATE) { + const flushStart = performance.now(); + const changesDetected = await this.#flushPendingChanges(); + if (changesDetected) { + this.#resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; + } + if (log.isLevelEnabled("perf")) { + log.perf( + `Flushed pending changes for project ${this.#project.getName()} ` + + `in ${(performance.now() - flushStart).toFixed(2)} ms`); + } + this.#combinedIndexState = INDEX_STATES.FRESH; } - const findStart = performance.now(); - const changedResources = await this.#findResultCache(); - if (log.isLevelEnabled("perf")) { - log.perf( - `Validated result cache for project ${this.#project.getName()} ` + - `in ${(performance.now() - findStart).toFixed(2)} ms`); + + if (this.#resultCacheState === RESULT_CACHE_STATES.PENDING_VALIDATION) { + log.verbose(`Project ${this.#project.getName()} cache requires validation due to detected changes.`); + const findStart = performance.now(); + const changedResourcesOrFalse = await this.#findResultCache(); + if (log.isLevelEnabled("perf")) { + log.perf( + `Validated result cache for project ${this.#project.getName()} ` + + `in ${(performance.now() - findStart).toFixed(2)} ms`); + } + if (changedResourcesOrFalse) { + this.#resultCacheState = RESULT_CACHE_STATES.FRESH_AND_IN_USE; + } else { + this.#resultCacheState = RESULT_CACHE_STATES.NO_CACHE; + } + return changedResourcesOrFalse; } - return changedResources; + return this.isFresh(); } /** * Processes changed resources since last build, updating indices and invalidating tasks as needed * - * @returns {Promise} + * @returns {Promise} */ async #flushPendingChanges() { if (this.#changedProjectSourcePaths.length === 0 && @@ -174,16 +201,16 @@ export default class ProjectBuildCache { })); } + // Reset pending changes + this.#changedProjectSourcePaths = []; + this.#changedDependencyResourcePaths = []; + if (sourceIndexChanged || depIndicesChanged) { // Relevant resources have changed, mark the cache as invalidated - this.#cacheState = CACHE_STATES.INVALIDATED; + return true; } else { log.verbose(`No relevant resource changes detected for project ${this.#project.getName()}`); } - - // Reset pending changes - this.#changedProjectSourcePaths = []; - this.#changedDependencyResourcePaths = []; } /** @@ -194,25 +221,10 @@ export default class ProjectBuildCache { * @param {@ui5/fs/AbstractReader} dependencyReader Reader for dependency resources * @returns {Promise} */ - async _initDependencyIndices(dependencyReader) { - if (this.#cacheState === CACHE_STATES.EMPTY) { - // No need to update indices for empty cache - return false; - } - let depIndicesChanged = false; + async _refreshDependencyIndices(dependencyReader) { await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { - const changed = await taskCache.refreshDependencyIndices(dependencyReader); - if (changed) { - depIndicesChanged = true; - } + await taskCache.refreshDependencyIndices(dependencyReader); })); - if (depIndicesChanged) { - // Relevant resources have changed, mark the cache as invalidated - this.#cacheState = CACHE_STATES.INVALIDATED; - } else if (this.#cacheState === CACHE_STATES.INITIALIZING) { - // Dependency index is up-to-date. Set cache state to initialized (if it was still initializing) - this.#cacheState = CACHE_STATES.INITIALIZED; - } // Reset pending dependency changes since indices are fresh now anyways this.#changedDependencyResourcePaths = []; } @@ -224,7 +236,8 @@ export default class ProjectBuildCache { * @returns {boolean} True if the cache is fresh */ isFresh() { - return this.#cacheState === CACHE_STATES.FRESH; + return this.#combinedIndexState === INDEX_STATES.FRESH && + this.#resultCacheState === RESULT_CACHE_STATES.FRESH_AND_IN_USE; } /** @@ -234,30 +247,15 @@ export default class ProjectBuildCache { * If found, creates a reader for the cached stage and sets it as the project's * result stage. * - * @returns {Promise} - * Array of resource paths written by the cached result stage, or undefined if no cache found + * @returns {Promise} + * Array of resource paths written by the cached result stage (empty if the result stage remains unchanged), + * or false if no cache found */ async #findResultCache() { - if (this.#cacheState === CACHE_STATES.REQUIRES_VALIDATION && this.#currentResultSignature) { - log.verbose( - `Project ${this.#project.getName()} cache requires validation but no changes have been detected. ` + - `Continuing with current result stage: ${this.#currentResultSignature}`); - this.#cacheState = CACHE_STATES.FRESH; - return []; - } - - if (![ - CACHE_STATES.REQUIRES_VALIDATION, CACHE_STATES.INVALIDATED, CACHE_STATES.INITIALIZED - ].includes(this.#cacheState)) { - log.verbose(`Project ${this.#project.getName()} cache state is ${this.#cacheState}, ` + - `skipping result cache validation.`); - return; - } const resultSignatures = this.#getPossibleResultStageSignatures(); if (resultSignatures.includes(this.#currentResultSignature)) { log.verbose( `Project ${this.#project.getName()} result stage signature unchanged: ${this.#currentResultSignature}`); - this.#cacheState = CACHE_STATES.FRESH; return []; } @@ -274,7 +272,7 @@ export default class ProjectBuildCache { log.verbose( `No cached stage found for project ${this.#project.getName()}. Searched with ` + `${resultSignatures.length} possible signatures.`); - return; + return false; } const [resultSignature, resultMetadata] = res; log.verbose(`Found result cache with signature ${resultSignature}`); @@ -286,7 +284,6 @@ export default class ProjectBuildCache { `Using cached result stage for project ${this.#project.getName()} with index signature ${resultSignature}`); this.#currentResultSignature = resultSignature; this.#cachedResultSignature = resultSignature; - this.#cacheState = CACHE_STATES.FRESH; return writtenResourcePaths; } @@ -685,7 +682,9 @@ export default class ProjectBuildCache { } /** - * Records changed source files of the project and marks cache as requiring validation + * Records changed source files of the project and marks cache as requiring validation. + * This method must not be called during creation of the ProjectBuildCache or while the project is being built to + * avoid inconsistent result and cache corruption. * * @public * @param {string[]} changedPaths Changed project source file paths @@ -696,14 +695,16 @@ export default class ProjectBuildCache { this.#changedProjectSourcePaths.push(resourcePath); } } - if (this.#cacheState !== CACHE_STATES.EMPTY) { - // If there is a cache, mark it as requiring validation - this.#cacheState = CACHE_STATES.REQUIRES_VALIDATION; + if (this.#combinedIndexState !== INDEX_STATES.INITIAL) { + // If there is an index cache, mark it as requiring update + this.#combinedIndexState = INDEX_STATES.REQUIRES_UPDATE; } } /** - * Records changed dependency resources and marks cache as requiring validation + * Records changed dependency resources and marks cache as requiring validation. + * This method must not be called during creation of the ProjectBuildCache or while the project is being built to + * avoid inconsistent result and cache corruption. * * @public * @param {string[]} changedPaths Changed dependency resource paths @@ -714,9 +715,9 @@ export default class ProjectBuildCache { this.#changedDependencyResourcePaths.push(resourcePath); } } - if (this.#cacheState !== CACHE_STATES.EMPTY) { - // If there is a cache, mark it as requiring validation - this.#cacheState = CACHE_STATES.REQUIRES_VALIDATION; + if (this.#combinedIndexState !== INDEX_STATES.INITIAL) { + // If there is an index cache, mark it as requiring update + this.#combinedIndexState = INDEX_STATES.REQUIRES_UPDATE; } } @@ -749,7 +750,10 @@ export default class ProjectBuildCache { */ async allTasksCompleted() { this.#project.useResultStage(); - this.#cacheState = CACHE_STATES.FRESH; + if (this.#combinedIndexState === INDEX_STATES.INITIAL) { + this.#combinedIndexState = INDEX_STATES.FRESH; + } + this.#resultCacheState = RESULT_CACHE_STATES.FRESH_AND_IN_USE; const changedPaths = this.#writtenResultResourcePaths; this.#currentResultSignature = this.#getResultStageSignature(); @@ -817,20 +821,22 @@ export default class ProjectBuildCache { if (changedPaths.length) { // Relevant resources have changed, mark the cache as invalidated - this.#cacheState = CACHE_STATES.INVALIDATED; + // this.#resultCacheState = RESULT_CACHE_STATES.INVALIDATED; } else { // Source index is up-to-date, awaiting dependency indices validation // Status remains at initializing - this.#cacheState = CACHE_STATES.INITIALIZING; + // this.#resultCacheState = RESULT_CACHE_STATES.INITIALIZING; this.#cachedSourceSignature = resourceIndex.getSignature(); } this.#sourceIndex = resourceIndex; // Since all source files are part of the result, declare any detected changes as newly written resources this.#writtenResultResourcePaths = changedPaths; + // Now awaiting initialization of dependency indices + this.#combinedIndexState = INDEX_STATES.RESTORING_DEPENDENCY_INDICES; } else { // No index cache found, create new index this.#sourceIndex = await ResourceIndex.create(resources, Date.now()); - this.#cacheState = CACHE_STATES.EMPTY; + this.#combinedIndexState = INDEX_STATES.INITIAL; } } @@ -838,7 +844,7 @@ export default class ProjectBuildCache { * Updates the source index with changed resource paths * * @param {string[]} changedResourcePaths Array of changed resource paths - * @returns {Promise} True if index was updated + * @returns {Promise} True if changes were detected, false otherwise */ async #updateSourceIndex(changedResourcePaths) { const sourceReader = this.#project.getSourceReader(); @@ -877,9 +883,10 @@ export default class ProjectBuildCache { * Stores all cache data to persistent storage * * This method: - * 1. Stores the result stage with all resources - * 2. Writes the resource index and task metadata - * 3. Stores all stage caches from the queue + * 1. Stores the signatures of all stages that lead to the current build result + * 2. Writes all pending task stage caches to persistent storage + * 3. Writes task request metadata to persistent storage + * 4. Writes the source resource index to persistent storage * * @public * @returns {Promise} @@ -889,8 +896,8 @@ export default class ProjectBuildCache { await Promise.all([ this.#writeResultCache(), - this.#writeTaskStageCaches(), - this.#writeTaskMetadataCaches(), + this.#writeTaskStageCache(), + this.#writeTaskRequestCache(), this.#writeSourceIndex(), ]); @@ -902,11 +909,8 @@ export default class ProjectBuildCache { } /** - * Writes the result metadata to persistent cache storage - * - * Collects all resources from the result stage (excluding source reader), - * stores their content via the cache manager, and writes stage metadata - * including resource information. + * Stores the signatures of all stages that lead to the current build result. This can be used to + * recreate the build result * * @returns {Promise} */ @@ -934,7 +938,7 @@ export default class ProjectBuildCache { * * @returns {Promise} */ - async #writeTaskStageCaches() { + async #writeTaskStageCache() { if (!this.#stageCache.hasPendingCacheQueue()) { return; } @@ -1001,11 +1005,11 @@ export default class ProjectBuildCache { } /** - * Writes task metadata caches to persistent storage + * Writes task request metadata to persistent storage * * @returns {Promise} */ - async #writeTaskMetadataCaches() { + async #writeTaskRequestCache() { // Store task caches for (const [taskName, taskCache] of this.#taskCache) { if (taskCache.hasNewOrModifiedCacheEntries()) { diff --git a/packages/project/lib/build/cache/ResourceRequestGraph.js b/packages/project/lib/build/cache/ResourceRequestGraph.js index 73655cace6b..fb152ec7e55 100644 --- a/packages/project/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/lib/build/cache/ResourceRequestGraph.js @@ -661,7 +661,7 @@ export default class ResourceRequestGraph { * @param {number} metadata.nextId Next available node ID * @returns {ResourceRequestGraph} Reconstructed graph instance */ - static fromCacheObject(metadata) { + static fromCache(metadata) { const graph = new ResourceRequestGraph(); // Restore nextId diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 1affba69208..cc1872623d9 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -68,7 +68,7 @@ class ResourceRequestManager { static fromCache(projectName, taskName, useDifferentialUpdate, { requestSetGraph, rootIndices, deltaIndices, unusedAtLeastOnce }) { - const requestGraph = ResourceRequestGraph.fromCacheObject(requestSetGraph); + const requestGraph = ResourceRequestGraph.fromCache(requestSetGraph); const resourceRequestManager = new ResourceRequestManager( projectName, taskName, useDifferentialUpdate, requestGraph, unusedAtLeastOnce); const registries = new Map(); @@ -134,12 +134,12 @@ class ResourceRequestManager { * * @public * @param {module:@ui5/fs.AbstractReader} reader Reader for accessing project resources - * @returns {Promise} True if any changes were detected, false otherwise + * @returns {Promise} */ async refreshIndices(reader) { if (this.#requestGraph.getSize() === 0) { // No requests recorded -> No updates necessary - return false; + return; } const resourceCache = new Map(); @@ -164,6 +164,8 @@ class ResourceRequestManager { await resourceIndex.upsertResources(resourcesToUpdate); } } + + await this.#flushTreeChangesWithoutDiffTracking(); } /** diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index f9b3f3d39b2..904e002db81 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -258,7 +258,7 @@ class ProjectBuildContext { * Creates a dependency reader and validates the cache state against current resources. * Must be called before buildProject(). * - * @returns {Promise} + * @returns {Promise} * True if a valid cache was found and is being used. False otherwise (indicating a build is required). */ async prepareProjectBuildAndValidateCache() { diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 013f820d67c..ff3bfa4825c 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -436,7 +436,7 @@ test("prepareProjectBuildAndValidateCache: returns false for empty cache", async t.is(result, false, "Returns false for empty cache"); }); -test("_initDependencyIndices: updates dependency indices", async (t) => { +test("_refreshDependencyIndices: updates dependency indices", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); @@ -504,7 +504,7 @@ test("_initDependencyIndices: updates dependency indices", async (t) => { byPath: sinon.stub().resolves(null) }; - await cache._initDependencyIndices(mockDependencyReader); + await cache._refreshDependencyIndices(mockDependencyReader); t.pass("Dependency indices refreshed"); }); diff --git a/packages/project/test/lib/build/cache/ResourceRequestGraph.js b/packages/project/test/lib/build/cache/ResourceRequestGraph.js index 36ee2387c4d..6f4e7f7089f 100644 --- a/packages/project/test/lib/build/cache/ResourceRequestGraph.js +++ b/packages/project/test/lib/build/cache/ResourceRequestGraph.js @@ -418,7 +418,7 @@ test("ResourceRequestGraph: toCacheObject exports graph structure", (t) => { t.deepEqual(exportedNode2.addedRequests, ["path:b.js"]); }); -test("ResourceRequestGraph: fromCacheObject reconstructs graph", (t) => { +test("ResourceRequestGraph: fromCache reconstructs graph", (t) => { const graph1 = new ResourceRequestGraph(); const set1 = [new Request("path", "a.js")]; @@ -432,7 +432,7 @@ test("ResourceRequestGraph: fromCacheObject reconstructs graph", (t) => { // Export and reconstruct const exported = graph1.toCacheObject(); - const graph2 = ResourceRequestGraph.fromCacheObject(exported); + const graph2 = ResourceRequestGraph.fromCache(exported); // Verify reconstruction t.is(graph2.nodes.size, 2); diff --git a/packages/project/test/lib/build/cache/ResourceRequestManager.js b/packages/project/test/lib/build/cache/ResourceRequestManager.js index 0d142c491dd..7bedc40abaa 100644 --- a/packages/project/test/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/test/lib/build/cache/ResourceRequestManager.js @@ -469,7 +469,8 @@ test("ResourceRequestManager: updateIndices with removed resource", async (t) => // ===== refreshIndices TESTS ===== -test("ResourceRequestManager: refreshIndices with no requests", async (t) => { +/* eslint-disable-next-line */ +test.skip("ResourceRequestManager: refreshIndices with no requests", async (t) => { const reader = createMockReader(new Map()); const manager = new ResourceRequestManager("test.project", "myTask", false); From f825f8b017e2228b390721e3ea1f25163c53e7fe Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 4 Feb 2026 16:27:39 +0100 Subject: [PATCH 141/223] test(project): Update node_modules deps to be aligned with actual fixtures folders --- .../node_modules/.package-lock.json | 24 ++++++++++++++ .../collection/library.a/package.json | 8 ----- .../library.a/src/library/a/.library | 2 +- .../library.b/src/library/b/.library | 2 +- .../node_modules/library.d/package.json | 9 ------ .../library.d/src/library/d/.library | 11 ------- .../library.d/test/library/d/Test.html | 0 .../node_modules/library.d/ui5.yaml | 10 ------ .../node_modules/collection/package.json | 12 +++---- .../node_modules/collection/test.js | 4 +++ .../library.d/main/src/library/d/.library | 2 +- .../node_modules/library.d/ui5.yaml | 1 + .../fixtures/application.a/package-lock.json | 32 +++++++++++++++++++ .../project/test/fixtures/collection/ui5.yaml | 12 +++++++ 14 files changed, 81 insertions(+), 48 deletions(-) create mode 100644 packages/project/test/fixtures/application.a/node_modules/.package-lock.json delete mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json delete mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library delete mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html delete mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml create mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/test.js create mode 100644 packages/project/test/fixtures/application.a/package-lock.json create mode 100644 packages/project/test/fixtures/collection/ui5.yaml diff --git a/packages/project/test/fixtures/application.a/node_modules/.package-lock.json b/packages/project/test/fixtures/application.a/node_modules/.package-lock.json new file mode 100644 index 00000000000..45bebbfe3da --- /dev/null +++ b/packages/project/test/fixtures/application.a/node_modules/.package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "application.a", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/collection": { + "version": "1.0.0", + "resolved": "file:../collection", + "workspaces": [ + "library.a", + "library.b", + "library.c" + ], + "dependencies": { + "library.d": "file:../library.d" + } + }, + "node_modules/library.d": { + "version": "1.0.0", + "resolved": "file:../library.d" + } + } +} diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json index 2179673d41d..aec498f7283 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json +++ b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json @@ -5,13 +5,5 @@ "dependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" - }, - "ui5": { - "name": "library.a", - "type": "library", - "settings": { - "src": "src", - "test": "test" - } } } diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library index 25c8603f31a..ef0ea1065bc 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library +++ b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library @@ -3,7 +3,7 @@ library.a SAP SE - ${copyright} + Some fancy copyright ${currentYear} ${version} Library A diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library b/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library index 36052acebdc..7128151f3f4 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library +++ b/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library @@ -3,7 +3,7 @@ library.b SAP SE - ${copyright} + Some fancy copyright ${currentYear} ${version} Library B diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json deleted file mode 100644 index 90c75040abe..00000000000 --- a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "library.d", - "version": "1.0.0", - "description": "Simple SAPUI5 based library", - "dependencies": {}, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - } -} diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library deleted file mode 100644 index 21251d1bbba..00000000000 --- a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library +++ /dev/null @@ -1,11 +0,0 @@ - - - - library.d - SAP SE - ${copyright} - ${version} - - Library D - - diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml deleted file mode 100644 index a47c1f64c3d..00000000000 --- a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -specVersion: "2.3" -type: library -metadata: - name: library.d -resources: - configuration: - paths: - src: main/src - test: main/test diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/package.json b/packages/project/test/fixtures/application.a/node_modules/collection/package.json index 81b948438bd..24849dbe4a8 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/package.json +++ b/packages/project/test/fixtures/application.a/node_modules/collection/package.json @@ -8,11 +8,9 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, - "collection": { - "modules": { - "library.a": "./library.a", - "library.b": "./library.b", - "library.c": "./library.c" - } - } + "workspaces": [ + "library.a", + "library.b", + "library.c" + ] } diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/test.js b/packages/project/test/fixtures/application.a/node_modules/collection/test.js new file mode 100644 index 00000000000..d063db1e726 --- /dev/null +++ b/packages/project/test/fixtures/application.a/node_modules/collection/test.js @@ -0,0 +1,4 @@ +import {globby} from 'globby'; + +const paths = await globby(["library.a"]); +console.log("paths") diff --git a/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library b/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library index 53c2d14c9d6..21251d1bbba 100644 --- a/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library +++ b/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library @@ -3,7 +3,7 @@ library.d SAP SE - Some fancy copyright + ${copyright} ${version} Library D diff --git a/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml index a47c1f64c3d..9d1317fba3f 100644 --- a/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml +++ b/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml @@ -3,6 +3,7 @@ specVersion: "2.3" type: library metadata: name: library.d + copyright: Some fancy copyright resources: configuration: paths: diff --git a/packages/project/test/fixtures/application.a/package-lock.json b/packages/project/test/fixtures/application.a/package-lock.json new file mode 100644 index 00000000000..0cd37cf4571 --- /dev/null +++ b/packages/project/test/fixtures/application.a/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "application.a", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "application.a", + "version": "1.0.0", + "dependencies": { + "collection": "file:../collection", + "library.d": "file:../library.d" + } + }, + "node_modules/collection": { + "version": "1.0.0", + "resolved": "file:../collection", + "workspaces": [ + "library.a", + "library.b", + "library.c" + ], + "dependencies": { + "library.d": "file:../library.d" + } + }, + "node_modules/library.d": { + "version": "1.0.0", + "resolved": "file:../library.d" + } + } +} diff --git a/packages/project/test/fixtures/collection/ui5.yaml b/packages/project/test/fixtures/collection/ui5.yaml new file mode 100644 index 00000000000..e47048de6a7 --- /dev/null +++ b/packages/project/test/fixtures/collection/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "2.1" +metadata: + name: application.a.collection.dependency.shim +kind: extension +type: project-shim +shims: + collections: + collection: + modules: + "library.a": "./library.a" + "library.b": "./library.b" + "library.c": "./library.c" \ No newline at end of file From ff609536f6509042c49ff959ebd686364d54dc73 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 5 Feb 2026 16:16:25 +0100 Subject: [PATCH 142/223] test(project): Add "library" dependencies to "module" fixture `+` Mark module test as incorrect with comment --- .../module.b/node_modules/.package-lock.json | 28 +++++++++++++++ .../collection/library.a/package.json | 9 +++++ .../library.a/src/library/a/.library | 17 +++++++++ .../library/a/themes/base/library.source.less | 6 ++++ .../library.a/test/library/a/Test.html | 0 .../collection/library.a/ui5.yaml | 5 +++ .../collection/library.b/package.json | 9 +++++ .../library.b/src/library/b/.library | 17 +++++++++ .../library.b/test/library/b/Test.html | 0 .../collection/library.b/ui5.yaml | 5 +++ .../collection/library.c/package.json | 9 +++++ .../library.c/src/library/c/.library | 17 +++++++++ .../library.c/test/LibraryC/Test.html | 0 .../collection/library.c/ui5.yaml | 5 +++ .../node_modules/collection/package.json | 16 +++++++++ .../module.b/node_modules/collection/test.js | 4 +++ .../module.b/node_modules/collection/ui5.yaml | 12 +++++++ .../library.d/main/src/library/d/.library | 11 ++++++ .../library.d/main/src/library/d/some.js | 4 +++ .../library.d/main/test/library/d/Test.html | 0 .../node_modules/library.d/package.json | 9 +++++ .../module.b/node_modules/library.d/ui5.yaml | 11 ++++++ .../test/fixtures/module.b/package-lock.json | 36 +++++++++++++++++++ .../test/fixtures/module.b/package.json | 6 +++- .../lib/build/ProjectBuilder.integration.js | 15 +++++++- 25 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 packages/project/test/fixtures/module.b/node_modules/.package-lock.json create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.a/package.json create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/.library create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/themes/base/library.source.less create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.a/test/library/a/Test.html create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.a/ui5.yaml create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.b/package.json create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.b/src/library/b/.library create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.b/test/library/b/Test.html create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.b/ui5.yaml create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.c/package.json create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.c/src/library/c/.library create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.c/test/LibraryC/Test.html create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/library.c/ui5.yaml create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/package.json create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/test.js create mode 100644 packages/project/test/fixtures/module.b/node_modules/collection/ui5.yaml create mode 100644 packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/.library create mode 100644 packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/some.js create mode 100644 packages/project/test/fixtures/module.b/node_modules/library.d/main/test/library/d/Test.html create mode 100644 packages/project/test/fixtures/module.b/node_modules/library.d/package.json create mode 100644 packages/project/test/fixtures/module.b/node_modules/library.d/ui5.yaml create mode 100644 packages/project/test/fixtures/module.b/package-lock.json diff --git a/packages/project/test/fixtures/module.b/node_modules/.package-lock.json b/packages/project/test/fixtures/module.b/node_modules/.package-lock.json new file mode 100644 index 00000000000..ba2e1378c35 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/.package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "module.b", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "../library.d": { + "version": "1.0.0", + "extraneous": true + }, + "node_modules/collection": { + "version": "1.0.0", + "resolved": "file:../collection", + "workspaces": [ + "library.a", + "library.b", + "library.c" + ], + "dependencies": { + "library.d": "file:../library.d" + } + }, + "node_modules/library.d": { + "version": "1.0.0", + "resolved": "file:../library.d" + } + } +} diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.a/package.json b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/package.json new file mode 100644 index 00000000000..aec498f7283 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.a", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/.library b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/.library new file mode 100644 index 00000000000..ef0ea1065bc --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/.library @@ -0,0 +1,17 @@ + + + + library.a + SAP SE + Some fancy copyright ${currentYear} + ${version} + + Library A + + + + library.d + + + + diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/themes/base/library.source.less b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/themes/base/library.source.less new file mode 100644 index 00000000000..ff0f1d5e3df --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/src/library/a/themes/base/library.source.less @@ -0,0 +1,6 @@ +@libraryAColor1: lightgoldenrodyellow; + +.library-a-foo { + color: @libraryAColor1; + padding: 1px 2px 3px 4px; +} diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.a/test/library/a/Test.html b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/test/library/a/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.a/ui5.yaml b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/ui5.yaml new file mode 100644 index 00000000000..8d4784313c3 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.a/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.a diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.b/package.json b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/package.json new file mode 100644 index 00000000000..2a0243b1683 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.b", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.b/src/library/b/.library b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/src/library/b/.library new file mode 100644 index 00000000000..7128151f3f4 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/src/library/b/.library @@ -0,0 +1,17 @@ + + + + library.b + SAP SE + Some fancy copyright ${currentYear} + ${version} + + Library B + + + + library.d + + + + diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.b/test/library/b/Test.html b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/test/library/b/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.b/ui5.yaml b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/ui5.yaml new file mode 100644 index 00000000000..b2fe5be59ee --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.b/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.b diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.c/package.json b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/package.json new file mode 100644 index 00000000000..64ac75d6ffe --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.c", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.c/src/library/c/.library b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/src/library/c/.library new file mode 100644 index 00000000000..4180ce2af2f --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/src/library/c/.library @@ -0,0 +1,17 @@ + + + + library.c + SAP SE + ${copyright} + ${version} + + Library C + + + + library.d + + + + diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.c/test/LibraryC/Test.html b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/test/LibraryC/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/library.c/ui5.yaml b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/ui5.yaml new file mode 100644 index 00000000000..7c5e38a7fc1 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/library.c/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.c diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/package.json b/packages/project/test/fixtures/module.b/node_modules/collection/package.json new file mode 100644 index 00000000000..24849dbe4a8 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/package.json @@ -0,0 +1,16 @@ +{ + "name": "collection", + "version": "1.0.0", + "description": "Simple Collection", + "dependencies": { + "library.d": "file:../library.d" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "workspaces": [ + "library.a", + "library.b", + "library.c" + ] +} diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/test.js b/packages/project/test/fixtures/module.b/node_modules/collection/test.js new file mode 100644 index 00000000000..d063db1e726 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/test.js @@ -0,0 +1,4 @@ +import {globby} from 'globby'; + +const paths = await globby(["library.a"]); +console.log("paths") diff --git a/packages/project/test/fixtures/module.b/node_modules/collection/ui5.yaml b/packages/project/test/fixtures/module.b/node_modules/collection/ui5.yaml new file mode 100644 index 00000000000..e47048de6a7 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/collection/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "2.1" +metadata: + name: application.a.collection.dependency.shim +kind: extension +type: project-shim +shims: + collections: + collection: + modules: + "library.a": "./library.a" + "library.b": "./library.b" + "library.c": "./library.c" \ No newline at end of file diff --git a/packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/.library b/packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/.library new file mode 100644 index 00000000000..21251d1bbba --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + ${copyright} + ${version} + + Library D + + diff --git a/packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/some.js b/packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/some.js new file mode 100644 index 00000000000..81e73436075 --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/library.d/main/src/library/d/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); \ No newline at end of file diff --git a/packages/project/test/fixtures/module.b/node_modules/library.d/main/test/library/d/Test.html b/packages/project/test/fixtures/module.b/node_modules/library.d/main/test/library/d/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/module.b/node_modules/library.d/package.json b/packages/project/test/fixtures/module.b/node_modules/library.d/package.json new file mode 100644 index 00000000000..90c75040abe --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/library.d/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.d", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/module.b/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/module.b/node_modules/library.d/ui5.yaml new file mode 100644 index 00000000000..9d1317fba3f --- /dev/null +++ b/packages/project/test/fixtures/module.b/node_modules/library.d/ui5.yaml @@ -0,0 +1,11 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.d + copyright: Some fancy copyright +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/packages/project/test/fixtures/module.b/package-lock.json b/packages/project/test/fixtures/module.b/package-lock.json new file mode 100644 index 00000000000..fcbbe63defc --- /dev/null +++ b/packages/project/test/fixtures/module.b/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "module.b", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "module.b", + "version": "1.0.0", + "dependencies": { + "collection": "file:../collection", + "library.d": "file:../library.d" + } + }, + "../library.d": { + "version": "1.0.0", + "extraneous": true + }, + "node_modules/collection": { + "version": "1.0.0", + "resolved": "file:../collection", + "workspaces": [ + "library.a", + "library.b", + "library.c" + ], + "dependencies": { + "library.d": "file:../library.d" + } + }, + "node_modules/library.d": { + "version": "1.0.0", + "resolved": "file:../library.d" + } + } +} diff --git a/packages/project/test/fixtures/module.b/package.json b/packages/project/test/fixtures/module.b/package.json index 77806dbb4c6..384989cb3da 100644 --- a/packages/project/test/fixtures/module.b/package.json +++ b/packages/project/test/fixtures/module.b/package.json @@ -1,5 +1,9 @@ { "name": "module.b", "version": "1.0.0", - "description": "Custom UI5 module" + "description": "Custom UI5 module", + "dependencies": { + "library.d": "file:../library.d", + "collection": "file:../collection" + } } diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index de0dbef6bd0..3253bd4e4de 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -556,7 +556,7 @@ test.serial("Build module.b project multiple times", async (t) => { await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { - projects: {} + projects: {} // FIXME: Currently not correct }, }); @@ -626,6 +626,19 @@ resources: // Check that the added file is NOT in the destPath anymore await t.throwsAsync(fs.readFile(`${destPath}/resources/b/module/newFolder/newFile.js`, {encoding: "utf8"})); + + // #5 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + } + }, + }); }); function getFixturePath(fixtureName) { From 5b790060f33d8b0b7bd72f1039daddeb9bca57ba Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 9 Feb 2026 14:04:34 +0100 Subject: [PATCH 143/223] test(project): Update ProjectBuildContext tests --- .../lib/build/helpers/ProjectBuildContext.js | 340 +++++++++--------- 1 file changed, 167 insertions(+), 173 deletions(-) diff --git a/packages/project/test/lib/build/helpers/ProjectBuildContext.js b/packages/project/test/lib/build/helpers/ProjectBuildContext.js index 74b06d49927..02f967371ed 100644 --- a/packages/project/test/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/test/lib/build/helpers/ProjectBuildContext.js @@ -1,7 +1,6 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; -import ProjectBuildCache from "../../../../lib/build/helpers/ProjectBuildCache.js"; import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; test.beforeEach((t) => { @@ -17,20 +16,22 @@ import ProjectBuildContext from "../../../../lib/build/helpers/ProjectBuildConte test("Missing parameters", (t) => { t.throws(() => { - new ProjectBuildContext({ - project: { + new ProjectBuildContext( + undefined, + { getName: () => "project", getType: () => "type", - }, - }); + } + ); }, { message: `Missing parameter 'buildContext'` }, "Correct error message"); t.throws(() => { - new ProjectBuildContext({ - buildContext: "buildContext", - }); + new ProjectBuildContext( + "buildContext", + undefined + ); }, { message: `Missing parameter 'project'` }, "Correct error message"); @@ -41,54 +42,61 @@ test("isRootProject: true", (t) => { getName: () => "root project", getType: () => "type", }; - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getRootProject: () => rootProject - }, - project: rootProject - }); + const buildContext = { + getRootProject: () => rootProject + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + rootProject + ); t.true(projectBuildContext.isRootProject(), "Correctly identified root project"); }); test("isRootProject: false", (t) => { - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getRootProject: () => "root project" - }, - project: { - getName: () => "not the root project", - getType: () => "type", - } - }); + const buildContext = { + getRootProject: () => "root project" + }; + const project = { + getName: () => "not the root project", + getType: () => "type", + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project + ); t.false(projectBuildContext.isRootProject(), "Correctly identified non-root project"); }); test("getBuildOption", (t) => { const getOptionStub = sinon.stub().returns("pony"); - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getOption: getOptionStub - }, - project: { - getName: () => "project", - getType: () => "type", - } - }); + const buildContext = { + getOption: getOptionStub + }; + const project = { + getName: () => "project", + getType: () => "type", + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project + ); t.is(projectBuildContext.getOption("option"), "pony", "Returned value is correct"); t.is(getOptionStub.getCall(0).args[0], "option", "getOption called with correct argument"); }); test("registerCleanupTask", (t) => { - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, - project: { - getName: () => "project", - getType: () => "type", - } - }); + const buildContext = {}; + const project = { + getName: () => "project", + getType: () => "type", + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project + ); projectBuildContext.registerCleanupTask("my task 1"); projectBuildContext.registerCleanupTask("my task 2"); @@ -97,13 +105,15 @@ test("registerCleanupTask", (t) => { }); test("executeCleanupTasks", (t) => { - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, - project: { - getName: () => "project", - getType: () => "type", - } - }); + const buildContext = {}; + const project = { + getName: () => "project", + getType: () => "type", + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project + ); const task1 = sinon.stub().resolves(); const task2 = sinon.stub().resolves(); projectBuildContext.registerCleanupTask(task1); @@ -143,13 +153,15 @@ test.serial("getResourceTagCollection", async (t) => { const ProjectBuildContext = await esmock("../../../../lib/build/helpers/ProjectBuildContext.js", { "@ui5/fs/internal/ResourceTagCollection": DummyResourceTagCollection }); - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, - project: { - getName: () => "project", - getType: () => "type", - } - }); + const buildContext = {}; + const project = { + getName: () => "project", + getType: () => "type", + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project + ); const fakeProjectCollection = { acceptsTag: projectAcceptsTagStub @@ -182,13 +194,11 @@ test("getResourceTagCollection: Assigns project to resource if necessary", (t) = getName: () => "project", getType: () => "type", }; - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, - project: fakeProject, - log: { - silly: () => {} - } - }); + const buildContext = {}; + const projectBuildContext = new ProjectBuildContext( + buildContext, + fakeProject + ); const setProjectStub = sinon.stub(); const fakeResource = { @@ -216,16 +226,17 @@ test("getProject", (t) => { getType: () => "type", }; const getProjectStub = sinon.stub().returns("pony"); - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getGraph: () => { - return { - getProject: getProjectStub - }; - } - }, + const buildContext = { + getGraph: () => { + return { + getProject: getProjectStub + }; + } + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); + ); t.is(projectBuildContext.getProject("pony project"), "pony", "Returned correct value"); t.is(getProjectStub.callCount, 1, "ProjectGraph#getProject got called once"); @@ -241,16 +252,17 @@ test("getProject: No name provided", (t) => { getType: () => "type", }; const getProjectStub = sinon.stub().returns("pony"); - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getGraph: () => { - return { - getProject: getProjectStub - }; - } - }, + const buildContext = { + getGraph: () => { + return { + getProject: getProjectStub + }; + } + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); + ); t.is(projectBuildContext.getProject(), project, "Returned correct value"); t.is(getProjectStub.callCount, 0, "ProjectGraph#getProject has not been called"); @@ -262,16 +274,17 @@ test("getDependencies", (t) => { getType: () => "type", }; const getDependenciesStub = sinon.stub().returns(["dep a", "dep b"]); - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getGraph: () => { - return { - getDependencies: getDependenciesStub - }; - } - }, + const buildContext = { + getGraph: () => { + return { + getDependencies: getDependenciesStub + }; + } + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); + ); t.deepEqual(projectBuildContext.getDependencies("pony project"), ["dep a", "dep b"], "Returned correct value"); t.is(getDependenciesStub.callCount, 1, "ProjectGraph#getDependencies got called once"); @@ -285,16 +298,17 @@ test("getDependencies: No name provided", (t) => { getType: () => "type", }; const getDependenciesStub = sinon.stub().returns(["dep a", "dep b"]); - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getGraph: () => { - return { - getDependencies: getDependenciesStub - }; - } - }, + const buildContext = { + getGraph: () => { + return { + getDependencies: getDependenciesStub + }; + } + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); + ); t.deepEqual(projectBuildContext.getDependencies(), ["dep a", "dep b"], "Returned correct value"); t.is(getDependenciesStub.callCount, 1, "ProjectGraph#getDependencies got called once"); @@ -303,13 +317,15 @@ test("getDependencies: No name provided", (t) => { }); test("getTaskUtil", (t) => { - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, - project: { - getName: () => "project", - getType: () => "type", - } - }); + const buildContext = {}; + const project = { + getName: () => "project", + getType: () => "type", + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project + ); t.truthy(projectBuildContext.getTaskUtil(), "Returned a TaskUtil instance"); t.is(projectBuildContext.getTaskUtil(), projectBuildContext.getTaskUtil(), "Caches TaskUtil instance"); @@ -326,13 +342,14 @@ test.serial("getTaskRunner", async (t) => { constructor(params) { t.true(params.log instanceof ProjectBuildLogger, "TaskRunner receives an instance of ProjectBuildLogger"); params.log = "log"; // replace log instance with string for deep comparison - t.true(params.cache instanceof ProjectBuildCache, "TaskRunner receives an instance of ProjectBuildCache"); - params.cache = "cache"; // replace cache instance with string for deep comparison + t.is(params.buildCache, buildCache, + "TaskRunner receives the ProjectBuildCache instance"); + params.buildCache = "buildCache"; // replace buildCache instance with string for deep comparison t.deepEqual(params, { graph: "graph", project: project, log: "log", - cache: "cache", + buildCache: "buildCache", taskUtil: "taskUtil", taskRepository: "taskRepository", buildConfig: "buildConfig" @@ -343,14 +360,18 @@ test.serial("getTaskRunner", async (t) => { "../../../../lib/build/TaskRunner.js": TaskRunnerMock }); - const projectBuildContext = new ProjectBuildContext({ - buildContext: { - getGraph: () => "graph", - getTaskRepository: () => "taskRepository", - getBuildConfig: () => "buildConfig", - }, - project - }); + const buildContext = { + getGraph: () => "graph", + getTaskRepository: () => "taskRepository", + getBuildConfig: () => "buildConfig", + }; + const buildCache = {}; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project, + undefined, + buildCache, + ); projectBuildContext.getTaskUtil = () => "taskUtil"; @@ -358,76 +379,44 @@ test.serial("getTaskRunner", async (t) => { t.is(projectBuildContext.getTaskRunner(), taskRunner, "Returns cached TaskRunner instance"); }); - -test.serial("createProjectContext", async (t) => { - t.plan(4); - - const project = { - getName: sinon.stub().returns("foo"), - getType: sinon.stub().returns("bar"), - }; - const taskRunner = {"task": "runner"}; - class ProjectContextMock { - constructor({buildContext, project}) { - t.is(buildContext, testBuildContext, "Correct buildContext parameter"); - t.is(project, project, "Correct project parameter"); - } - getTaskUtil() { - return "taskUtil"; - } - setTaskRunner(_taskRunner) { - t.is(_taskRunner, taskRunner); - } - } - const BuildContext = await esmock("../../../../lib/build/helpers/BuildContext.js", { - "../../../../lib/build/helpers/ProjectBuildContext.js": ProjectContextMock, - "../../../../lib/build/TaskRunner.js": { - create: sinon.stub().resolves(taskRunner) - } - }); - const graph = { - getRoot: () => ({getType: () => "library"}), - }; - const testBuildContext = new BuildContext(graph, "taskRepository"); - - const projectContext = await testBuildContext.createProjectContext({ - project - }); - - t.true(projectContext instanceof ProjectContextMock, - "Project context is an instance of ProjectContextMock"); - t.is(testBuildContext._projectBuildContexts[0], projectContext, - "BuildContext stored correct ProjectBuildContext"); -}); - -test("requiresBuild: has no build-manifest", (t) => { +test("possiblyRequiresBuild: has no build-manifest", (t) => { const project = { getName: sinon.stub().returns("foo"), getType: sinon.stub().returns("bar"), getBuildManifest: () => null }; - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, - project - }); - t.true(projectBuildContext.requiresBuild(), "Project without build-manifest requires to be build"); + const buildContext = {}; + const buildCache = { + isFresh: sinon.stub().returns(false) + }; + const projectBuildContext = new ProjectBuildContext( + buildContext, + project, + undefined, + buildCache + ); + t.true(projectBuildContext.possiblyRequiresBuild(), "Project without build-manifest requires to be build"); }); -test("requiresBuild: has build-manifest", (t) => { +test("possiblyRequiresBuild: has build-manifest", (t) => { const project = { getName: sinon.stub().returns("foo"), getType: sinon.stub().returns("bar"), getBuildManifest: () => { return { + buildManifest: { + manifestVersion: "0.1" + }, timestamp: "2022-07-28T12:00:00.000Z" }; } }; - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, + const buildContext = {}; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); - t.false(projectBuildContext.requiresBuild(), "Project with build-manifest does not require to be build"); + ); + t.false(projectBuildContext.possiblyRequiresBuild(), "Project with build-manifest does not require to be build"); }); test.serial("getBuildMetadata", (t) => { @@ -436,15 +425,19 @@ test.serial("getBuildMetadata", (t) => { getType: sinon.stub().returns("bar"), getBuildManifest: () => { return { + buildManifest: { + manifestVersion: "0.1" + }, timestamp: "2022-07-28T12:00:00.000Z" }; } }; const getTimeStub = sinon.stub(Date.prototype, "getTime").callThrough().onFirstCall().returns(1659016800000); - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, + const buildContext = {}; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); + ); t.deepEqual(projectBuildContext.getBuildMetadata(), { timestamp: "2022-07-28T12:00:00.000Z", @@ -459,9 +452,10 @@ test("getBuildMetadata: has no build-manifest", (t) => { getType: sinon.stub().returns("bar"), getBuildManifest: () => null }; - const projectBuildContext = new ProjectBuildContext({ - buildContext: {}, + const buildContext = {}; + const projectBuildContext = new ProjectBuildContext( + buildContext, project - }); + ); t.is(projectBuildContext.getBuildMetadata(), null, "Project has no build manifest"); }); From d56ec31d00077e6d6aeddd453023daeeed806c9e Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 10 Feb 2026 10:37:20 +0100 Subject: [PATCH 144/223] Revert "test(project): Update node_modules deps to be aligned with actual fixtures folders" This reverts commit 806ab4da350b95374e4ecf131b344ff244a136a5. --- .../node_modules/.package-lock.json | 24 -------------- .../collection/library.a/package.json | 8 +++++ .../library.a/src/library/a/.library | 2 +- .../library.b/src/library/b/.library | 2 +- .../node_modules/library.d/package.json | 9 ++++++ .../library.d/src/library/d/.library | 11 +++++++ .../library.d/test/library/d/Test.html | 0 .../node_modules/library.d/ui5.yaml | 10 ++++++ .../node_modules/collection/package.json | 12 ++++--- .../node_modules/collection/test.js | 4 --- .../library.d/main/src/library/d/.library | 2 +- .../node_modules/library.d/ui5.yaml | 1 - .../fixtures/application.a/package-lock.json | 32 ------------------- .../project/test/fixtures/collection/ui5.yaml | 12 ------- 14 files changed, 48 insertions(+), 81 deletions(-) delete mode 100644 packages/project/test/fixtures/application.a/node_modules/.package-lock.json create mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json create mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library create mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html create mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml delete mode 100644 packages/project/test/fixtures/application.a/node_modules/collection/test.js delete mode 100644 packages/project/test/fixtures/application.a/package-lock.json delete mode 100644 packages/project/test/fixtures/collection/ui5.yaml diff --git a/packages/project/test/fixtures/application.a/node_modules/.package-lock.json b/packages/project/test/fixtures/application.a/node_modules/.package-lock.json deleted file mode 100644 index 45bebbfe3da..00000000000 --- a/packages/project/test/fixtures/application.a/node_modules/.package-lock.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "application.a", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "node_modules/collection": { - "version": "1.0.0", - "resolved": "file:../collection", - "workspaces": [ - "library.a", - "library.b", - "library.c" - ], - "dependencies": { - "library.d": "file:../library.d" - } - }, - "node_modules/library.d": { - "version": "1.0.0", - "resolved": "file:../library.d" - } - } -} diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json index aec498f7283..2179673d41d 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json +++ b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/package.json @@ -5,5 +5,13 @@ "dependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" + }, + "ui5": { + "name": "library.a", + "type": "library", + "settings": { + "src": "src", + "test": "test" + } } } diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library index ef0ea1065bc..25c8603f31a 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library +++ b/packages/project/test/fixtures/application.a/node_modules/collection/library.a/src/library/a/.library @@ -3,7 +3,7 @@ library.a SAP SE - Some fancy copyright ${currentYear} + ${copyright} ${version} Library A diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library b/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library index 7128151f3f4..36052acebdc 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library +++ b/packages/project/test/fixtures/application.a/node_modules/collection/library.b/src/library/b/.library @@ -3,7 +3,7 @@ library.b SAP SE - Some fancy copyright ${currentYear} + ${copyright} ${version} Library B diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json new file mode 100644 index 00000000000..90c75040abe --- /dev/null +++ b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.d", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library new file mode 100644 index 00000000000..21251d1bbba --- /dev/null +++ b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + ${copyright} + ${version} + + Library D + + diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/test/library/d/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml new file mode 100644 index 00000000000..a47c1f64c3d --- /dev/null +++ b/packages/project/test/fixtures/application.a/node_modules/collection/node_modules/library.d/ui5.yaml @@ -0,0 +1,10 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.d +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/package.json b/packages/project/test/fixtures/application.a/node_modules/collection/package.json index 24849dbe4a8..81b948438bd 100644 --- a/packages/project/test/fixtures/application.a/node_modules/collection/package.json +++ b/packages/project/test/fixtures/application.a/node_modules/collection/package.json @@ -8,9 +8,11 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, - "workspaces": [ - "library.a", - "library.b", - "library.c" - ] + "collection": { + "modules": { + "library.a": "./library.a", + "library.b": "./library.b", + "library.c": "./library.c" + } + } } diff --git a/packages/project/test/fixtures/application.a/node_modules/collection/test.js b/packages/project/test/fixtures/application.a/node_modules/collection/test.js deleted file mode 100644 index d063db1e726..00000000000 --- a/packages/project/test/fixtures/application.a/node_modules/collection/test.js +++ /dev/null @@ -1,4 +0,0 @@ -import {globby} from 'globby'; - -const paths = await globby(["library.a"]); -console.log("paths") diff --git a/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library b/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library index 21251d1bbba..53c2d14c9d6 100644 --- a/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library +++ b/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/.library @@ -3,7 +3,7 @@ library.d SAP SE - ${copyright} + Some fancy copyright ${version} Library D diff --git a/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml index 9d1317fba3f..a47c1f64c3d 100644 --- a/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml +++ b/packages/project/test/fixtures/application.a/node_modules/library.d/ui5.yaml @@ -3,7 +3,6 @@ specVersion: "2.3" type: library metadata: name: library.d - copyright: Some fancy copyright resources: configuration: paths: diff --git a/packages/project/test/fixtures/application.a/package-lock.json b/packages/project/test/fixtures/application.a/package-lock.json deleted file mode 100644 index 0cd37cf4571..00000000000 --- a/packages/project/test/fixtures/application.a/package-lock.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "application.a", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "application.a", - "version": "1.0.0", - "dependencies": { - "collection": "file:../collection", - "library.d": "file:../library.d" - } - }, - "node_modules/collection": { - "version": "1.0.0", - "resolved": "file:../collection", - "workspaces": [ - "library.a", - "library.b", - "library.c" - ], - "dependencies": { - "library.d": "file:../library.d" - } - }, - "node_modules/library.d": { - "version": "1.0.0", - "resolved": "file:../library.d" - } - } -} diff --git a/packages/project/test/fixtures/collection/ui5.yaml b/packages/project/test/fixtures/collection/ui5.yaml deleted file mode 100644 index e47048de6a7..00000000000 --- a/packages/project/test/fixtures/collection/ui5.yaml +++ /dev/null @@ -1,12 +0,0 @@ -specVersion: "2.1" -metadata: - name: application.a.collection.dependency.shim -kind: extension -type: project-shim -shims: - collections: - collection: - modules: - "library.a": "./library.a" - "library.b": "./library.b" - "library.c": "./library.c" \ No newline at end of file From e3de6c6c3dbbfcd3b9b6d04263a05dd4f4d22803 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 10 Feb 2026 11:13:03 +0100 Subject: [PATCH 145/223] refactor(project): Rename task param 'supportsDifferentialUpdates' => 'supportsDifferentialBuilds' For better consistency --- packages/project/lib/build/TaskRunner.js | 26 +++++++------- .../project/lib/build/cache/BuildTaskCache.js | 28 +++++++-------- .../lib/build/cache/ProjectBuildCache.js | 12 +++---- .../lib/build/definitions/application.js | 6 ++-- .../lib/build/definitions/component.js | 6 ++-- .../project/lib/build/definitions/library.js | 8 ++--- .../lib/build/definitions/themeLibrary.js | 4 +-- .../lib/specifications/extensions/Task.js | 4 +-- packages/project/test/lib/build/TaskRunner.js | 24 ++++++------- .../test/lib/build/cache/BuildTaskCache.js | 12 +++---- .../test/lib/build/definitions/application.js | 22 ++++++------ .../test/lib/build/definitions/component.js | 14 ++++---- .../test/lib/build/definitions/library.js | 36 +++++++++---------- .../lib/build/definitions/themeLibrary.js | 8 ++--- 14 files changed, 105 insertions(+), 105 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index e913892e3e4..1587b143525 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -195,7 +195,7 @@ class TaskRunner { * @param {object} [parameters] Task parameters * @param {boolean} [parameters.requiresDependencies=false] * Whether the task requires access to project dependencies - * @param {boolean} [parameters.supportsDifferentialUpdates=false] + * @param {boolean} [parameters.supportsDifferentialBuilds=false] * Whether the task supports differential updates using cache * @param {object} [parameters.options={}] Options to pass to the task * @param {Function|null} [parameters.taskFunction] @@ -203,7 +203,7 @@ class TaskRunner { * @returns {void} */ _addTask(taskName, { - requiresDependencies = false, supportsDifferentialUpdates = false, options = {}, taskFunction + requiresDependencies = false, supportsDifferentialBuilds = false, options = {}, taskFunction } = {}) { if (this._tasks[taskName]) { throw new Error(`Failed to add duplicate task ${taskName} for project ${this._project.getName()}`); @@ -227,7 +227,7 @@ class TaskRunner { this._log.skipTask(taskName); return; } - const usingCache = !!(supportsDifferentialUpdates && cacheInfo); + const usingCache = !!(supportsDifferentialBuilds && cacheInfo); const workspace = createMonitor(this._project.getWorkspace()); const params = { workspace, @@ -262,7 +262,7 @@ class TaskRunner { workspace.getResourceRequests(), dependencies?.getResourceRequests(), usingCache ? cacheInfo : undefined, - supportsDifferentialUpdates); + supportsDifferentialBuilds); }; } this._tasks[taskName] = { @@ -351,7 +351,7 @@ class TaskRunner { const requiredDependenciesCallback = await task.getRequiredDependenciesCallback(); // const buildSignatureCallback = await task.getBuildSignatureCallback(); // const expectedOutputCallback = await task.getExpectedOutputCallback(); - const supportsDifferentialUpdatesCallback = await task.getSupportsDifferentialUpdatesCallback(); + const supportsDifferentialBuildsCallback = await task.getSupportsDifferentialBuildsCallback(); const specVersion = task.getSpecVersion(); let requiredDependencies; @@ -414,9 +414,9 @@ class TaskRunner { } }); } - let supportsDifferentialUpdates = false; - if (specVersion.gte("5.0") && supportsDifferentialUpdatesCallback && supportsDifferentialUpdatesCallback()) { - supportsDifferentialUpdates = true; + let supportsDifferentialBuilds = false; + if (specVersion.gte("5.0") && supportsDifferentialBuildsCallback && supportsDifferentialBuildsCallback()) { + supportsDifferentialBuilds = true; } this._tasks[newTaskName] = { @@ -427,7 +427,7 @@ class TaskRunner { taskName: newTaskName, taskConfiguration: taskDef.configuration, provideDependenciesReader, - supportsDifferentialUpdates, + supportsDifferentialBuilds, getDependenciesReaderCb: () => { // Create the dependencies reader on-demand return this.getDependenciesReader(requiredDependencies); @@ -479,7 +479,7 @@ class TaskRunner { * Callback to get dependencies reader on-demand * @param {boolean} parameters.provideDependenciesReader * Whether to provide dependencies reader to the task - * @param {boolean} parameters.supportsDifferentialUpdates + * @param {boolean} parameters.supportsDifferentialBuilds * Whether the task supports differential updates * @param {@ui5/project/specifications/Extension} parameters.task Task extension instance * @param {string} parameters.taskName Runtime name of the task (may include suffix) @@ -487,7 +487,7 @@ class TaskRunner { * @returns {Function} Async wrapper function for the custom task */ _createCustomTaskWrapper({ - project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, supportsDifferentialUpdates, + project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, supportsDifferentialBuilds, task, taskName, taskConfiguration }) { return async () => { @@ -496,7 +496,7 @@ class TaskRunner { this._log.skipTask(taskName); return; } - const usingCache = !!(supportsDifferentialUpdates && cacheInfo); + const usingCache = !!(supportsDifferentialBuilds && cacheInfo); /* Custom Task Interface Parameters: @@ -560,7 +560,7 @@ class TaskRunner { workspace.getResourceRequests(), dependencies?.getResourceRequests(), usingCache ? cacheInfo : undefined, - supportsDifferentialUpdates); + supportsDifferentialBuilds); }; } diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index bfb405414d1..b2aac3cfe90 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -30,7 +30,7 @@ const log = getLogger("build:cache:BuildTaskCache"); export default class BuildTaskCache { #projectName; #taskName; - #supportsDifferentialUpdates; + #supportsDifferentialBuilds; #projectRequestManager; #dependencyRequestManager; @@ -41,22 +41,22 @@ export default class BuildTaskCache { * @public * @param {string} projectName Name of the project this task belongs to * @param {string} taskName Name of the task this cache manages - * @param {boolean} supportsDifferentialUpdates Whether the task supports differential updates + * @param {boolean} supportsDifferentialBuilds Whether the task supports differential updates * @param {ResourceRequestManager} [projectRequestManager] Optional pre-existing project request manager from cache * @param {ResourceRequestManager} [dependencyRequestManager] * Optional pre-existing dependency request manager from cache */ - constructor(projectName, taskName, supportsDifferentialUpdates, projectRequestManager, dependencyRequestManager) { + constructor(projectName, taskName, supportsDifferentialBuilds, projectRequestManager, dependencyRequestManager) { this.#projectName = projectName; this.#taskName = taskName; - this.#supportsDifferentialUpdates = supportsDifferentialUpdates; + this.#supportsDifferentialBuilds = supportsDifferentialBuilds; log.verbose(`Initializing BuildTaskCache for task "${taskName}" of project "${this.#projectName}" ` + - `(supportsDifferentialUpdates=${supportsDifferentialUpdates})`); + `(supportsDifferentialBuilds=${supportsDifferentialBuilds})`); this.#projectRequestManager = projectRequestManager ?? - new ResourceRequestManager(projectName, taskName, supportsDifferentialUpdates); + new ResourceRequestManager(projectName, taskName, supportsDifferentialBuilds); this.#dependencyRequestManager = dependencyRequestManager ?? - new ResourceRequestManager(projectName, taskName, supportsDifferentialUpdates); + new ResourceRequestManager(projectName, taskName, supportsDifferentialBuilds); } /** @@ -68,17 +68,17 @@ export default class BuildTaskCache { * @public * @param {string} projectName Name of the project * @param {string} taskName Name of the task - * @param {boolean} supportsDifferentialUpdates Whether the task supports differential updates + * @param {boolean} supportsDifferentialBuilds Whether the task supports differential updates * @param {object} projectRequests Cached project request manager data * @param {object} dependencyRequests Cached dependency request manager data * @returns {BuildTaskCache} Restored task cache instance */ - static fromCache(projectName, taskName, supportsDifferentialUpdates, projectRequests, dependencyRequests) { + static fromCache(projectName, taskName, supportsDifferentialBuilds, projectRequests, dependencyRequests) { const projectRequestManager = ResourceRequestManager.fromCache(projectName, taskName, - supportsDifferentialUpdates, projectRequests); + supportsDifferentialBuilds, projectRequests); const dependencyRequestManager = ResourceRequestManager.fromCache(projectName, taskName, - supportsDifferentialUpdates, dependencyRequests); - return new BuildTaskCache(projectName, taskName, supportsDifferentialUpdates, + supportsDifferentialBuilds, dependencyRequests); + return new BuildTaskCache(projectName, taskName, supportsDifferentialBuilds, projectRequestManager, dependencyRequestManager); } @@ -103,8 +103,8 @@ export default class BuildTaskCache { * @public * @returns {boolean} True if differential updates are supported */ - getSupportsDifferentialUpdates() { - return this.#supportsDifferentialUpdates; + getSupportsDifferentialBuilds() { + return this.#supportsDifferentialBuilds; } /** diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 84073c1b5d6..db569902aed 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -595,16 +595,16 @@ export default class ProjectBuildCache { * @param {@ui5/project/build/cache/BuildTaskCache~ResourceRequests|undefined} dependencyResourceRequests * Resource requests for dependency resources * @param {object} cacheInfo Cache information for differential updates - * @param {boolean} supportsDifferentialUpdates Whether the task supports differential updates + * @param {boolean} supportsDifferentialBuilds Whether the task supports differential updates * @returns {Promise} */ async recordTaskResult( - taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo, supportsDifferentialUpdates + taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo, supportsDifferentialBuilds ) { if (!this.#taskCache.has(taskName)) { // Initialize task cache this.#taskCache.set(taskName, - new BuildTaskCache(this.#project.getName(), taskName, supportsDifferentialUpdates)); + new BuildTaskCache(this.#project.getName(), taskName, supportsDifferentialBuilds)); } log.verbose(`Recording results of task ${taskName} in project ${this.#project.getName()}...`); const taskCache = this.#taskCache.get(taskName); @@ -797,7 +797,7 @@ export default class ProjectBuildCache { // Import task caches const buildTaskCaches = await Promise.all( - indexCache.tasks.map(async ([taskName, supportsDifferentialUpdates]) => { + indexCache.tasks.map(async ([taskName, supportsDifferentialBuilds]) => { const projectRequests = await this.#cacheManager.readTaskMetadata( this.#project.getId(), this.#buildSignature, taskName, "project"); if (!projectRequests) { @@ -810,7 +810,7 @@ export default class ProjectBuildCache { throw new Error(`Failed to load dependency request cache for task ` + `${taskName} in project ${this.#project.getName()}`); } - return BuildTaskCache.fromCache(this.#project.getName(), taskName, !!supportsDifferentialUpdates, + return BuildTaskCache.fromCache(this.#project.getName(), taskName, !!supportsDifferentialBuilds, projectRequests, dependencyRequests); }) ); @@ -1045,7 +1045,7 @@ export default class ProjectBuildCache { const sourceIndexObject = this.#sourceIndex.toCacheObject(); const tasks = []; for (const [taskName, taskCache] of this.#taskCache) { - tasks.push([taskName, taskCache.getSupportsDifferentialUpdates() ? 1 : 0]); + tasks.push([taskName, taskCache.getSupportsDifferentialBuilds() ? 1 : 0]); } await this.#cacheManager.writeIndexCache(this.#project.getId(), this.#buildSignature, "source", { ...sourceIndexObject, diff --git a/packages/project/lib/build/definitions/application.js b/packages/project/lib/build/definitions/application.js index 86873606872..9b502502836 100644 --- a/packages/project/lib/build/definitions/application.js +++ b/packages/project/lib/build/definitions/application.js @@ -20,7 +20,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,json}" @@ -28,7 +28,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json}" @@ -44,7 +44,7 @@ export default function({project, taskUtil, getTask}) { } } tasks.set("minify", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { pattern: minificationPattern } diff --git a/packages/project/lib/build/definitions/component.js b/packages/project/lib/build/definitions/component.js index 3fd7711855f..ac64531ee98 100644 --- a/packages/project/lib/build/definitions/component.js +++ b/packages/project/lib/build/definitions/component.js @@ -20,7 +20,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,json}" @@ -28,7 +28,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json}" @@ -43,7 +43,7 @@ export default function({project, taskUtil, getTask}) { } tasks.set("minify", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { pattern: minificationPattern } diff --git a/packages/project/lib/build/definitions/library.js b/packages/project/lib/build/definitions/library.js index 3f9b31ea5f2..ab7a0cca58e 100644 --- a/packages/project/lib/build/definitions/library.js +++ b/packages/project/lib/build/definitions/library.js @@ -20,7 +20,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,library,css,less,theme,html}" @@ -28,7 +28,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json,library,css,less,theme,html}" @@ -36,7 +36,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceBuildtime", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" } @@ -85,7 +85,7 @@ export default function({project, taskUtil, getTask}) { } tasks.set("minify", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { pattern: minificationPattern } diff --git a/packages/project/lib/build/definitions/themeLibrary.js b/packages/project/lib/build/definitions/themeLibrary.js index 4b70d01b872..00ef7424290 100644 --- a/packages/project/lib/build/definitions/themeLibrary.js +++ b/packages/project/lib/build/definitions/themeLibrary.js @@ -11,7 +11,7 @@ export default function({project, taskUtil, getTask}) { const tasks = new Map(); tasks.set("replaceCopyright", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { copyright: project.getCopyright(), pattern: "/resources/**/*.{less,theme}" @@ -19,7 +19,7 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, options: { version: project.getVersion(), pattern: "/resources/**/*.{less,theme}" diff --git a/packages/project/lib/specifications/extensions/Task.js b/packages/project/lib/specifications/extensions/Task.js index 7878737488f..9964dbe43f3 100644 --- a/packages/project/lib/specifications/extensions/Task.js +++ b/packages/project/lib/specifications/extensions/Task.js @@ -41,8 +41,8 @@ class Task extends Extension { /** * @public */ - async getSupportsDifferentialUpdatesCallback() { - return (await this._getImplementation()).supportsDifferentialUpdates; + async getSupportsDifferentialBuildsCallback() { + return (await this._getImplementation()).supportsDifferentialBuilds; } /** diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index 4ef8fc11afe..8fd3f9addc2 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -98,7 +98,7 @@ test.beforeEach(async (t) => { }; }, getRequiredDependenciesCallback: t.context.getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false), + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false), }; t.context.graph = { @@ -686,7 +686,7 @@ test("Custom task is called correctly", async (t) => { getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); t.context.taskUtil.getInterface.returns("taskUtil interface"); const project = getMockProject("module"); @@ -747,7 +747,7 @@ test("Custom task with legacy spec version", async (t) => { getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); t.context.taskUtil.getInterface.returns(undefined); // simulating no taskUtil for old specVersion const project = getMockProject("module"); @@ -809,7 +809,7 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); t.context.taskUtil.getInterface.returns(undefined); // simulating no taskUtil for old specVersion const project = getMockProject("module"); @@ -885,7 +885,7 @@ test("Custom task with specVersion 3.0", async (t) => { getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -982,7 +982,7 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -1062,28 +1062,28 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { getTask: () => taskStubA, getSpecVersion: () => mockSpecVersionA, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); graph.getExtension.onSecondCall().returns({ getName: () => "Task Name B", getTask: () => taskStubB, getSpecVersion: () => mockSpecVersionB, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); graph.getExtension.onThirdCall().returns({ getName: () => "Task Name C", getTask: () => taskStubC, getSpecVersion: () => mockSpecVersionC, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); graph.getExtension.onCall(3).returns({ getName: () => "Task Name D", getTask: () => taskStubD, getSpecVersion: () => mockSpecVersionD, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); project.getCustomTasks = () => [ @@ -1242,7 +1242,7 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); @@ -1280,7 +1280,7 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, - getSupportsDifferentialUpdatesCallback: sinon.stub().returns(() => false) + getSupportsDifferentialBuildsCallback: sinon.stub().returns(() => false) }); const project = getMockProject("module"); diff --git a/packages/project/test/lib/build/cache/BuildTaskCache.js b/packages/project/test/lib/build/cache/BuildTaskCache.js index d48169552fd..a72a9cc234d 100644 --- a/packages/project/test/lib/build/cache/BuildTaskCache.js +++ b/packages/project/test/lib/build/cache/BuildTaskCache.js @@ -44,13 +44,13 @@ test("Create BuildTaskCache instance", (t) => { t.truthy(cache, "BuildTaskCache instance created"); t.is(cache.getTaskName(), "testTask", "Task name matches"); - t.is(cache.getSupportsDifferentialUpdates(), false, "Differential updates disabled"); + t.is(cache.getSupportsDifferentialBuilds(), false, "Differential updates disabled"); }); test("Create with differential updates enabled", (t) => { const cache = new BuildTaskCache("test.project", "testTask", true); - t.is(cache.getSupportsDifferentialUpdates(), true, "Differential updates enabled"); + t.is(cache.getSupportsDifferentialBuilds(), true, "Differential updates enabled"); }); test("fromCache: restore BuildTaskCache from cached data", (t) => { @@ -79,7 +79,7 @@ test("fromCache: restore BuildTaskCache from cached data", (t) => { t.truthy(cache, "Cache restored from cached data"); t.is(cache.getTaskName(), "testTask", "Task name preserved"); - t.is(cache.getSupportsDifferentialUpdates(), false, "Differential updates setting preserved"); + t.is(cache.getSupportsDifferentialBuilds(), false, "Differential updates setting preserved"); }); // ===== METADATA ACCESS TESTS ===== @@ -90,12 +90,12 @@ test("getTaskName: returns task name", (t) => { t.is(cache.getTaskName(), "myTask", "Task name returned"); }); -test("getSupportsDifferentialUpdates: returns correct value", (t) => { +test("getSupportsDifferentialBuilds: returns correct value", (t) => { const cache1 = new BuildTaskCache("test.project", "task1", false); const cache2 = new BuildTaskCache("test.project", "task2", true); - t.false(cache1.getSupportsDifferentialUpdates(), "Returns false when disabled"); - t.true(cache2.getSupportsDifferentialUpdates(), "Returns true when enabled"); + t.false(cache1.getSupportsDifferentialBuilds(), "Returns false when disabled"); + t.true(cache2.getSupportsDifferentialBuilds(), "Returns true when enabled"); }); test("hasNewOrModifiedCacheEntries: initially true for new instance", (t) => { diff --git a/packages/project/test/lib/build/definitions/application.js b/packages/project/test/lib/build/definitions/application.js index e44ef37b1d1..cc6fb8eabee 100644 --- a/packages/project/test/lib/build/definitions/application.js +++ b/packages/project/test/lib/build/definitions/application.js @@ -58,13 +58,13 @@ test("Standard build", (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, minify: { options: { @@ -73,7 +73,7 @@ test("Standard build", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -141,13 +141,13 @@ test("Standard build with legacy spec version", (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, minify: { options: { @@ -156,7 +156,7 @@ test("Standard build with legacy spec version", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -257,13 +257,13 @@ test("Custom bundles", async (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, minify: { options: { @@ -272,7 +272,7 @@ test("Custom bundles", async (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -406,7 +406,7 @@ test("Minification excludes", (t) => { "!/resources/**.html", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, "Correct minify task definition"); }); @@ -432,7 +432,7 @@ test("Minification excludes not applied for legacy specVersion", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, "Correct minify task definition"); }); diff --git a/packages/project/test/lib/build/definitions/component.js b/packages/project/test/lib/build/definitions/component.js index 9be7b4d5549..1e773369234 100644 --- a/packages/project/test/lib/build/definitions/component.js +++ b/packages/project/test/lib/build/definitions/component.js @@ -57,13 +57,13 @@ test("Standard build", (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, minify: { options: { @@ -72,7 +72,7 @@ test("Standard build", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -166,13 +166,13 @@ test("Custom bundles", async (t) => { options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, minify: { options: { @@ -181,7 +181,7 @@ test("Custom bundles", async (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, enhanceManifest: {}, generateFlexChangesBundle: {}, @@ -308,7 +308,7 @@ test("Minification excludes", (t) => { "!/resources/**.html", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, "Correct minify task definition"); }); diff --git a/packages/project/test/lib/build/definitions/library.js b/packages/project/test/lib/build/definitions/library.js index e49979b3b86..3914be256da 100644 --- a/packages/project/test/lib/build/definitions/library.js +++ b/packages/project/test/lib/build/definitions/library.js @@ -69,20 +69,20 @@ test("Standard build", async (t) => { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateJsdoc: { requiresDependencies: true, @@ -101,7 +101,7 @@ test("Standard build", async (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateLibraryManifest: {}, enhanceManifest: {}, @@ -210,20 +210,20 @@ test("Standard build with legacy spec version", (t) => { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateJsdoc: { requiresDependencies: true, @@ -242,7 +242,7 @@ test("Standard build with legacy spec version", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateLibraryManifest: {}, enhanceManifest: {}, @@ -340,20 +340,20 @@ test("Custom bundles", async (t) => { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateJsdoc: { requiresDependencies: true, @@ -372,7 +372,7 @@ test("Custom bundles", async (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateLibraryManifest: {}, enhanceManifest: {}, @@ -502,7 +502,7 @@ test("Minification excludes", (t) => { "!/resources/**.html", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, "Correct minify task definition"); }); @@ -528,7 +528,7 @@ test("Minification excludes not applied for legacy specVersion", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, "Correct minify task definition"); }); @@ -690,20 +690,20 @@ test("Standard build: nulled taskFunction to skip tasks", (t) => { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceBuildtime: { options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateJsdoc: { requiresDependencies: true, @@ -722,7 +722,7 @@ test("Standard build: nulled taskFunction to skip tasks", (t) => { "!**/*.support.js", ] }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, generateLibraryManifest: {}, enhanceManifest: {}, diff --git a/packages/project/test/lib/build/definitions/themeLibrary.js b/packages/project/test/lib/build/definitions/themeLibrary.js index 6201d67e318..9d35d11a174 100644 --- a/packages/project/test/lib/build/definitions/themeLibrary.js +++ b/packages/project/test/lib/build/definitions/themeLibrary.js @@ -54,14 +54,14 @@ test("Standard build", (t) => { copyright: "copyright", pattern: "/resources/**/*.{less,theme}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/resources/**/*.{less,theme}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, buildThemes: { requiresDependencies: true, @@ -117,14 +117,14 @@ test("Standard build for non root project", (t) => { copyright: "copyright", pattern: "/resources/**/*.{less,theme}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, replaceVersion: { options: { version: "version", pattern: "/resources/**/*.{less,theme}" }, - supportsDifferentialUpdates: true, + supportsDifferentialBuilds: true, }, buildThemes: { requiresDependencies: true, From 016dec2efdc12ebc9eb0979d86256820c3caf674 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 10 Feb 2026 12:06:50 +0100 Subject: [PATCH 146/223] test(project): Extend FixtureTester (ProjectBuilder) to work with non-task project types (e.g. modules) `+` Fix module test with now new assertions --- .../lib/build/ProjectBuilder.integration.js | 82 ++++++++----------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 3253bd4e4de..bfa46e829fc 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -556,7 +556,7 @@ test.serial("Build module.b project multiple times", async (t) => { await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { - projects: {} // FIXME: Currently not correct + projects: {"module.b": {}} }, }); @@ -568,26 +568,22 @@ test.serial("Build module.b project multiple times", async (t) => { } }); - // Add new folder (with files) - await fs.mkdir(`${fixtureTester.fixturePath}/newFolder`, {recursive: true}); - await fs.writeFile(`${fixtureTester.fixturePath}/newFolder/newFile.js`, - `console.log("This is a new file in a new folder.");` - ); - // Update path mapping of ui5.yaml to include new folder - await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, - `--- -specVersion: "5.0" -type: module -metadata: - name: module.b -resources: - configuration: - paths: - /resources/b/module/dev/: dev - /resources/b/module/newFolder/: newFolder` - ); + // Remove a source file in module.b + await fs.rm(`${fixtureTester.fixturePath}/dev/devTools.js`); // #3 build (no cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"module.b": {}} + } + }); + + // Check that the removed file is NOT in the destPath anymore + // (dist output should be totally empty: no source files -> no build result) + await t.throwsAsync(fs.readFile(`${destPath}/resources/b/module/dev/devTools.js`, {encoding: "utf8"})); + + // #4 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -595,39 +591,21 @@ resources: } }); - // Check whether the added file is in the destPath - const builtFileContent = await fs.readFile(`${destPath}/resources/b/module/newFolder/newFile.js`, - {encoding: "utf8"}); - t.true(builtFileContent.includes(`console.log("This is a new file in a new folder.");`), - "Build dest contains changed file content"); - - // Delete the new folder and its contents again - await fs.rm(`${fixtureTester.fixturePath}/newFolder`, {recursive: true, force: true}); - // Remove the path mapping from ui5.yaml again (Revert to original) - await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, - `--- -specVersion: "5.0" -type: module -metadata: - name: module.b -resources: - configuration: - paths: - /resources/b/module/dev/: dev` - ); - // #4 build (no cache, with changes) + // Add a new file in module.b + await fs.mkdir(`${fixtureTester.fixturePath}/dev/newFolder`, {recursive: true}); + await fs.writeFile(`${fixtureTester.fixturePath}/dev/newFolder/newFile.js`, + `console.log("this is a new file which should be included in the build result")`); + + // #5 build (no cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { - projects: {} // everything should be skipped (already done in very first build) + projects: {"module.b": {}} }, }); - // Check that the added file is NOT in the destPath anymore - await t.throwsAsync(fs.readFile(`${destPath}/resources/b/module/newFolder/newFile.js`, {encoding: "utf8"})); - - // #5 build (with cache, no changes, with dependencies) + // #6 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { @@ -695,17 +673,25 @@ class FixtureTester { _assertBuild(assertions) { const {projects = {}} = assertions; - const eventArgs = this._t.context.projectBuildStatusEventStub.args.map((args) => args[0]); const projectsInOrder = []; const seenProjects = new Set(); const tasksByProject = {}; - for (const event of eventArgs) { + // Extract build status to identify built projects and their order + const buildStatusEvents = this._t.context.buildStatusEventStub.args.map((args) => args[0]); + for (const event of buildStatusEvents) { if (!seenProjects.has(event.projectName)) { - projectsInOrder.push(event.projectName); seenProjects.add(event.projectName); + if (event.status === "project-build-start") { + projectsInOrder.push(event.projectName); + } } + } + + // Extract task status to identify skipped & executed tasks per project + const projectBuildStatusEvents = this._t.context.projectBuildStatusEventStub.args.map((args) => args[0]); + for (const event of projectBuildStatusEvents) { if (!tasksByProject[event.projectName]) { tasksByProject[event.projectName] = {executed: [], skipped: []}; } From 723f2205eb0e8671f690641b0a96ab9c76a97e09 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 10 Feb 2026 14:34:55 +0100 Subject: [PATCH 147/223] test(project): Add cases for ui5.yaml path mapping (Modules) --- .../lib/build/ProjectBuilder.integration.js | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index bfa46e829fc..e79f530cfc4 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -551,7 +551,6 @@ test.serial("Build module.b project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "module.b"); const destPath = fixtureTester.destPath; - // #1 build (no cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -568,6 +567,7 @@ test.serial("Build module.b project multiple times", async (t) => { } }); + // Remove a source file in module.b await fs.rm(`${fixtureTester.fixturePath}/dev/devTools.js`); @@ -605,7 +605,65 @@ test.serial("Build module.b project multiple times", async (t) => { }, }); - // #6 build (with cache, no changes, with dependencies) + // Check whether the added file is in the destPath + const newFile = await fs.readFile(`${destPath}/resources/b/module/dev/newFolder/newFile.js`, + {encoding: "utf8"}); + t.true(newFile.includes(`this is a new file which should be included in the build result`), + "Build dest contains correct file content"); + + + // Add a new path mapping: + const originalUi5Yaml = await fs.readFile(`${fixtureTester.fixturePath}/ui5.yaml`, {encoding: "utf8"}); // for later + const newFileName = "someOtherNewFile.js"; + const newFolderName = "newPathmapping"; + const virtualPath = `/resources/b/module/${newFolderName}/`; + await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, + `--- +specVersion: "5.0" +type: module +metadata: + name: module.b +resources: + configuration: + paths: + /resources/b/module/dev/: dev + ${virtualPath}: ${newFolderName}` + ); + + // Create a resource for this new path mapping: + await fs.mkdir(`${fixtureTester.fixturePath}/${newFolderName}`, {recursive: true}); + await fs.writeFile(`${fixtureTester.fixturePath}/${newFolderName}/${newFileName}`, + `console.log("this is a new file which should be included in the build result via the new path mapping")`); + + // #6 build (no cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"module.b": {}} + }, + }); + + // Check whether the added file is in the destPath + const someOtherNewFile = await fs.readFile(`${destPath}${virtualPath}${newFileName}`, + {encoding: "utf8"}); + t.true(someOtherNewFile.includes(`via the new path mapping`), "Build dest contains correct file content"); + + // Remove the path mapping again (revert original ui5.yaml): + await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, originalUi5Yaml); + + // #7 build (with cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} // -> cache can be reused + }, + }); + + // Check that the added resource of the path mapping is NOT in the destPath anymore: + await t.throwsAsync(fs.readFile(`${destPath}${virtualPath}${newFileName}`, + {encoding: "utf8"})); + + // #8 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { From 0e0f98c7003b26c3df9e44bf56670e0b212bed64 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 10 Feb 2026 16:24:52 +0100 Subject: [PATCH 148/223] test(project): Add race condition test --- .../application.a/race-condition-task.js | 13 ++++ .../application.a/ui5-race-condition.yaml | 17 ++++++ .../lib/build/ProjectBuilder.integration.js | 61 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/race-condition-task.js create mode 100644 packages/project/test/fixtures/application.a/ui5-race-condition.yaml diff --git a/packages/project/test/fixtures/application.a/race-condition-task.js b/packages/project/test/fixtures/application.a/race-condition-task.js new file mode 100644 index 00000000000..e70ed51ce7f --- /dev/null +++ b/packages/project/test/fixtures/application.a/race-condition-task.js @@ -0,0 +1,13 @@ +const {readFile, writeFile} = require("fs/promises"); +const path = require("path"); + +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + const webappPath = taskUtil.getProject().getSourcePath(); + // Modify source file during build + const testFilePath = path.join(webappPath, "test.js"); + const originalContent = await readFile(testFilePath, {encoding: "utf8"}); + await writeFile(testFilePath, originalContent + `\nconsole.log("RACE CONDITION MODIFICATION");\n`); +}; diff --git a/packages/project/test/fixtures/application.a/ui5-race-condition.yaml b/packages/project/test/fixtures/application.a/ui5-race-condition.yaml new file mode 100644 index 00000000000..aa6d48ec208 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-race-condition.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: race-condition-task + afterTask: escapeNonAsciiCharacters +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: race-condition-task +task: + path: race-condition-task.js diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index e79f530cfc4..ac1847dda92 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -677,6 +677,67 @@ resources: }); }); +test.serial("Build race condition: file modified during active build", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + await fixtureTester._initialize(); + const testFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + const originalContent = await fs.readFile(testFilePath, {encoding: "utf8"}); + + // #1 Build with race condition triggered by custom task + // The custom task (configured in ui5-race-condition.yaml) modifies test.js during the build, + // after the source index is created but before tasks that process test.js execute. + // This creates a race condition where the cached content hash no longer matches the actual file. + // + // Expected behavior: + // - Build should detect that source file hash changed during execution + // - Build should fail with an error OR mark cache as invalid + // + // FIXME: Current behavior: + // - Build succeeds without detecting the race condition + // - Cache is written with inconsistent data (index hash != processed content hash) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-race-condition.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Verify the race condition occurred: the modification made by the custom task is in the output + const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true( + builtFileContent.includes(`RACE CONDITION MODIFICATION`), + "Build output contains the modification made during build" + ); + + // #2 Revert the source file to original content + await fs.writeFile(testFilePath, originalContent); + + // #3 Build again after reverting the source + // FIXME: The cache should be invalidated because the previous build had a race condition, + // but currently it's reused (projects: {}). Once proper validation is implemented, + // this should trigger a full rebuild: {"application.a": {}} + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-race-condition.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} // Current: cache reused | Expected: {"application.a": {}} + } + }); + + // FIXME: Due to incorrect cache reuse from build #1, the output still contains the modification + // even though the source was reverted. This demonstrates the cache corruption issue. + // Expected: finalBuiltContent should NOT contain "RACE CONDITION MODIFICATION" + const finalBuiltContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true( + finalBuiltContent.includes(`RACE CONDITION MODIFICATION`), + "Build output incorrectly contains the modification due to corrupted cache" + ); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } From 212a13ac7c32ba79a4104b04b27f506922dad434 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 11 Feb 2026 12:15:36 +0100 Subject: [PATCH 149/223] test(project): Add modify file case for modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ´+´ style: Apply same line padding: (within a build stage: 1 empty line; between two build stages: 2 empty lines) --- .../lib/build/ProjectBuilder.integration.js | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index ac1847dda92..4e0acc97011 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -49,6 +49,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // #2 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -57,6 +58,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // Change a source file in application.a const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; await fs.appendFile(changedFilePath, `\ntest("line added");\n`); @@ -83,6 +85,7 @@ test.serial("Build application.a project multiple times", async (t) => { const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); + // #4 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, @@ -96,6 +99,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // #5 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -104,6 +108,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // #6 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, @@ -112,6 +117,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // #6 build (with cache, no changes, with custom tasks) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, @@ -123,6 +129,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // #7 build (with cache, no changes, with custom tasks) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, @@ -132,6 +139,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // #8 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, @@ -140,6 +148,7 @@ test.serial("Build application.a project multiple times", async (t) => { } }); + // Change a source file with existing source map in application.a const fileWithSourceMapPath = `${fixtureTester.fixturePath}/webapp/thirdparty/scriptWithSourceMap.js`; @@ -195,6 +204,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) } }); + // Create new file which should get tagged as "OmitFromBuildResult" by a custom task await fs.writeFile(`${fixtureTester.fixturePath}/webapp/fileToBeOmitted.js`, `console.log("this file should be ommited in the build result")`); @@ -221,6 +231,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) // Check that fileToBeOmitted.js is not in dist await t.throwsAsync(fs.readFile(`${destPath}/fileToBeOmitted.js`, {encoding: "utf8"})); + // #3 build (with cache, no changes, with custom tasks) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, @@ -233,6 +244,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) // Check that fileToBeOmitted.js is not in dist again --> FIXME: Currently failing here await t.throwsAsync(fs.readFile(`${destPath}/fileToBeOmitted.js`, {encoding: "utf8"})); + // Delete the file again await fs.rm(`${fixtureTester.fixturePath}/webapp/fileToBeOmitted.js`); @@ -258,6 +270,7 @@ test.serial("Build library.d project multiple times", async (t) => { } }); + // #2 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -266,6 +279,7 @@ test.serial("Build library.d project multiple times", async (t) => { } }); + // Change a source file in library.d const changedFilePath = `${fixtureTester.fixturePath}/main/src/library/d/.library`; await fs.writeFile( @@ -305,6 +319,7 @@ test.serial("Build library.d project multiple times", async (t) => { "Build dest contains updated description in manifest.json" ); + // #4 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -313,6 +328,7 @@ test.serial("Build library.d project multiple times", async (t) => { } }); + // Update copyright in ui5.yaml (should trigger a full rebuild of the project) const ui5YamlPath = `${fixtureTester.fixturePath}/ui5.yaml`; await fs.writeFile( @@ -344,6 +360,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { } }); + // #2 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -352,10 +369,12 @@ test.serial("Build theme.library.e project multiple times", async (t) => { } }); + // Change a source file in theme.library.e const librarySourceFilePath = `${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/library.source.less`; await fs.appendFile(librarySourceFilePath, `\n.someNewClass {\n\tcolor: red;\n}\n`); + // #3 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -363,6 +382,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { projects: {"theme.library.e": {}} } }); + // Check whether the changed file is in the destPath const builtFileContent = await fs.readFile( `${destPath}/resources/theme/library/e/themes/my_theme/library.source.less`, {encoding: "utf8"} @@ -371,6 +391,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { builtFileContent.includes(`.someNewClass`), "Build dest contains changed file content" ); + // Check whether the build output contains the new CSS rule const builtCssContent = await fs.readFile( `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} @@ -380,11 +401,13 @@ test.serial("Build theme.library.e project multiple times", async (t) => { "Build dest contains new rule in library.css" ); + // Add a new less file and import it in library.source.less await fs.writeFile(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`, `.someOtherNewClass {\n\tcolor: blue;\n}\n` ); await fs.appendFile(librarySourceFilePath, `\n@import "newImportFile.less";\n`); + // #4 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -392,6 +415,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { projects: {"theme.library.e": {}}, } }); + // Check whether the build output contains the import to the new file const builtCssContent2 = await fs.readFile( `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} @@ -401,6 +425,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { "Build dest contains new rule in library.css" ); + // #5 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -409,10 +434,12 @@ test.serial("Build theme.library.e project multiple times", async (t) => { } }); + // Change content of new less file await fs.writeFile(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`, `.someOtherNewClass {\n\tcolor: green;\n}\n` ); + // #6 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -420,6 +447,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { projects: {"theme.library.e": {}}, } }); + // Check whether the build output contains the changed content of the imported file const builtCssContent3 = await fs.readFile( `${destPath}/resources/theme/library/e/themes/my_theme/library.css`, {encoding: "utf8"} @@ -429,15 +457,18 @@ test.serial("Build theme.library.e project multiple times", async (t) => { "Build dest contains new rule in library.css" ); + // Delete import of library.source.less const librarySourceFileContent = (await fs.readFile(librarySourceFilePath)).toString(); await fs.writeFile(librarySourceFilePath, librarySourceFileContent.replace(`\n@import "newImportFile.less";\n`, "") ); + // Change content of new less file again await fs.writeFile(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`, `.someOtherNewClass {\n\tcolor: yellow;\n}\n` ); + // #7 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -456,6 +487,7 @@ test.serial("Build theme.library.e project multiple times", async (t) => { "Build dest should NOT contain the rule in library.css anymore" ); + // Delete the imported less file await fs.rm(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/newImportFile.less`); @@ -472,7 +504,6 @@ test.serial("Build component.a project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "component.a"); const destPath = fixtureTester.destPath; - // #1 build (no cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -483,6 +514,7 @@ test.serial("Build component.a project multiple times", async (t) => { } }); + // #2 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -491,6 +523,7 @@ test.serial("Build component.a project multiple times", async (t) => { } }); + // Change a source file in component.a const changedFilePath = `${fixtureTester.fixturePath}/src/test.js`; await fs.appendFile(changedFilePath, `\ntest("line added");\n`); @@ -517,6 +550,7 @@ test.serial("Build component.a project multiple times", async (t) => { const builtFileContent = await fs.readFile(`${destPath}/resources/id1/test.js`, {encoding: "utf8"}); t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); + // #4 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, @@ -530,6 +564,7 @@ test.serial("Build component.a project multiple times", async (t) => { } }); + // #5 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -538,6 +573,7 @@ test.serial("Build component.a project multiple times", async (t) => { } }); + // #6 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, @@ -559,6 +595,7 @@ test.serial("Build module.b project multiple times", async (t) => { }, }); + // #2 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -568,10 +605,27 @@ test.serial("Build module.b project multiple times", async (t) => { }); + // Change a source file in module.b + const changedFilePath = `${fixtureTester.fixturePath}/dev/devTools.js`; + await fs.appendFile(changedFilePath, `\ntest("line added");\n`); + + // #3 build (no cache, with changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"module.b": {}} + } + }); + + // Check whether the changed file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/resources/b/module/dev/devTools.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); + + // Remove a source file in module.b await fs.rm(`${fixtureTester.fixturePath}/dev/devTools.js`); - // #3 build (no cache, with changes) + // #4 build (no cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -583,7 +637,8 @@ test.serial("Build module.b project multiple times", async (t) => { // (dist output should be totally empty: no source files -> no build result) await t.throwsAsync(fs.readFile(`${destPath}/resources/b/module/dev/devTools.js`, {encoding: "utf8"})); - // #4 build (with cache, no changes) + + // #5 build (with cache, no changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -597,7 +652,7 @@ test.serial("Build module.b project multiple times", async (t) => { await fs.writeFile(`${fixtureTester.fixturePath}/dev/newFolder/newFile.js`, `console.log("this is a new file which should be included in the build result")`); - // #5 build (no cache, with changes) + // #6 build (no cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -633,9 +688,9 @@ resources: // Create a resource for this new path mapping: await fs.mkdir(`${fixtureTester.fixturePath}/${newFolderName}`, {recursive: true}); await fs.writeFile(`${fixtureTester.fixturePath}/${newFolderName}/${newFileName}`, - `console.log("this is a new file which should be included in the build result via the new path mapping")`); + `console.log("this should be included in the build result if the path mapping has been set")`); - // #6 build (no cache, with changes) + // #7 build (no cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -646,12 +701,13 @@ resources: // Check whether the added file is in the destPath const someOtherNewFile = await fs.readFile(`${destPath}${virtualPath}${newFileName}`, {encoding: "utf8"}); - t.true(someOtherNewFile.includes(`via the new path mapping`), "Build dest contains correct file content"); + t.true(someOtherNewFile.includes(`path mapping has been set`), "Build dest contains correct file content"); + // Remove the path mapping again (revert original ui5.yaml): await fs.writeFile(`${fixtureTester.fixturePath}/ui5.yaml`, originalUi5Yaml); - // #7 build (with cache, with changes) + // #8 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -663,7 +719,8 @@ resources: await t.throwsAsync(fs.readFile(`${destPath}${virtualPath}${newFileName}`, {encoding: "utf8"})); - // #8 build (with cache, no changes, with dependencies) + + // #9 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { From a5291e635910325c73aa5ab0df6f06ff4bee31de Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Mon, 16 Feb 2026 17:47:00 +0100 Subject: [PATCH 150/223] test(project): Add case for multiple custom tasks (tag handling) --- .../custom-tasks/custom-task-0.js | 29 +++++++++ .../custom-tasks/custom-task-1.js | 12 ++++ .../custom-tasks/custom-task-2.js | 29 +++++++++ .../ui5-multiple-customTasks.yaml | 37 +++++++++++ .../lib/build/ProjectBuilder.integration.js | 64 ++++++++++++++++++- 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js create mode 100644 packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js create mode 100644 packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js create mode 100644 packages/project/test/fixtures/application.a/ui5-multiple-customTasks.yaml diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js new file mode 100644 index 00000000000..849dcc8d443 --- /dev/null +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js @@ -0,0 +1,29 @@ +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + console.log("Custom task 0 executed"); + + // For #1 Build: Read a file which is an input of custom-task-1 (which sets a tag on it) + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + + // For #3 Build: Read a different file (which is NOT an input of custom-task-1) + const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); + + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (!test2JS && testJS) { + // For #1 Build: + if (tag) { + throw new Error("Tag set by custom-task-1 is present in custom-task-0, which is UNEXPECTED."); + } else { + console.log("Tag set by custom-task-1 is not present in custom-task-0, as EXPECTED."); + } + } else { + // For #3 Build (NEW behavior expected as in #1): + if (tag) { + console.log("Tag set by custom-task-1 is present in custom-task-0 now, as EXPECTED."); + } else { + throw new Error("Tag set by custom-task-1 is NOT present in custom-task-0, which is UNEXPECTED."); + } + } +}; diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js new file mode 100644 index 00000000000..e4957154c8a --- /dev/null +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js @@ -0,0 +1,12 @@ +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + console.log("Custom task 1 executed"); + + // Set a tag on a specific resource + const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + if (resource) { + taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.IsDebugVariant); + }; +}; diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js new file mode 100644 index 00000000000..b0ecc052743 --- /dev/null +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js @@ -0,0 +1,29 @@ +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + console.log("Custom task 2 executed"); + + // For #1 Build: Read a file which is an input of custom-task-1 (which sets a tag on it) + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + + // For #3 Build: Read a different file (which is NOT an input of custom-task-1) + const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); + + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (!test2JS && testJS) { + // For #1 Build: + if (tag) { + console.log("Tag set by custom-task-1 is present in custom-task-2, as EXPECTED."); + } else { + throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); + } + } else { + // For #3 Build (SAME behavior expected as in #1): + if (tag) { + console.log("Tag set by custom-task-1 is present in custom-task-2, as EXPECTED."); + } else { + throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); + } + } +}; diff --git a/packages/project/test/fixtures/application.a/ui5-multiple-customTasks.yaml b/packages/project/test/fixtures/application.a/ui5-multiple-customTasks.yaml new file mode 100644 index 00000000000..bb99285b85c --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-multiple-customTasks.yaml @@ -0,0 +1,37 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: custom-task-0 + afterTask: minify + - name: custom-task-1 + afterTask: custom-task-0 + - name: custom-task-2 + afterTask: custom-task-1 +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: custom-task-0 +task: + path: custom-tasks/custom-task-0.js +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: custom-task-1 +task: + path: custom-tasks/custom-task-1.js +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: custom-task-2 +task: + path: custom-tasks/custom-task-2.js \ No newline at end of file diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 4e0acc97011..0336439d9e1 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -207,7 +207,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) // Create new file which should get tagged as "OmitFromBuildResult" by a custom task await fs.writeFile(`${fixtureTester.fixturePath}/webapp/fileToBeOmitted.js`, - `console.log("this file should be ommited in the build result")`); + `console.log("this file should be omitted in the build result")`); // #2 build (with cache, with changes, with custom tasks) await fixtureTester.buildProject({ @@ -258,6 +258,68 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) }); }); +// eslint-disable-next-line ava/no-skip-test -- tag handling to be implemented +test.serial.skip("Build application.a (multiple custom tasks)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // This test should cover a scenario with multiple custom tasks. + // Specifically, a tag is set in custom-task-1 on a resource which is read in custom-task-0 and custom-task-2. + // The expected behavior is that the tag is not present in custom-task-0 (which runs before custom-task-1), + // but is present in custom-task-2 (which runs after custom-task-1). + // (for testing purposes, the custom tasks already check for this tag by themselves and handle errors accordingly) + + // #1 build (no cache, no changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2 build (with cache, no changes, with custom tasks) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + + // Create a new file to allow a new build: + // Logic of custom-task-1 will NOT handle this file, while custom-task-0 and 2 WILL DO it, + // resulting in custom-task-1 getting skipped (cache reuse). + // The test should then verify that the tag is still set and now readable for custom-task-0 AND 2. + // (as in #1 build, the custom tasks already check for this tag by themselves and handle errors accordingly) + await fs.cp(`${fixtureTester.fixturePath}/webapp/test.js`, + `${fixtureTester.fixturePath}/webapp/test2.js`); + + // #3 build (with cache, with changes, with custom tasks) + // FIXME: Currently failing, because for custom-task-0 and 2 the tag is NOT set yet. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "custom-task-1", // SHOULD BE SKIPPED + // remaining skipped tasks don't matter here: + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; From 43e7e7a8dc17e5c35dbcf582205e6d0d80f8e20f Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 18 Feb 2026 14:15:13 +0100 Subject: [PATCH 151/223] fix(project): Fix build manifest access --- packages/project/lib/build/helpers/ProjectBuildContext.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 904e002db81..f2ee7de57b9 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -338,7 +338,7 @@ class ProjectBuildContext { return; } // Check whether the manifest can be used for this build - if (manifest.buildManifest.manifestVersion === "0.1" || manifest.buildManifest.manifestVersion === "0.2") { + if (manifest.manifestVersion === "0.1" || manifest.manifestVersion === "0.2") { // Manifest version 0.1 and 0.2 are always used without further checks for legacy reasons return manifest; } From bdae0d639381b6b8c64088bcd38e4a329543a491 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 18 Feb 2026 16:02:41 +0100 Subject: [PATCH 152/223] test(project): Fix multiple-task tests (Address review) `+` Fix logic for preceding tasks being able to read tags `+` Clean-up custom-task code --- .../custom-tasks/custom-task-0.js | 26 ++++++------------- .../custom-tasks/custom-task-1.js | 2 +- .../custom-tasks/custom-task-2.js | 26 ++++++------------- .../lib/build/ProjectBuilder.integration.js | 7 ++--- 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js index 849dcc8d443..753a5fbc1e9 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js @@ -4,26 +4,16 @@ module.exports = async function ({ }) { console.log("Custom task 0 executed"); - // For #1 Build: Read a file which is an input of custom-task-1 (which sets a tag on it) + // Read a file which is an input of custom-task-1 (which sets a tag on it): const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - // For #3 Build: Read a different file (which is NOT an input of custom-task-1) - const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); - const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - if (!test2JS && testJS) { - // For #1 Build: - if (tag) { - throw new Error("Tag set by custom-task-1 is present in custom-task-0, which is UNEXPECTED."); - } else { - console.log("Tag set by custom-task-1 is not present in custom-task-0, as EXPECTED."); - } - } else { - // For #3 Build (NEW behavior expected as in #1): - if (tag) { - console.log("Tag set by custom-task-1 is present in custom-task-0 now, as EXPECTED."); - } else { - throw new Error("Tag set by custom-task-1 is NOT present in custom-task-0, which is UNEXPECTED."); - } + // For #1 & #3 build: + if (tag) { + throw new Error("Tag set by custom-task-1 is present in custom-task-0, which is UNEXPECTED."); } + + // For #3 build: Read a different file which is not an input of custom-task-1 + // (ensures that this task is executed): + const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js index e4957154c8a..a2a992c9653 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js @@ -4,7 +4,7 @@ module.exports = async function ({ }) { console.log("Custom task 1 executed"); - // Set a tag on a specific resource + // Set a tag on a specific resource: const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); if (resource) { taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.IsDebugVariant); diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js index b0ecc052743..5cb3723e5f1 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js @@ -4,26 +4,16 @@ module.exports = async function ({ }) { console.log("Custom task 2 executed"); - // For #1 Build: Read a file which is an input of custom-task-1 (which sets a tag on it) + // Read a file which is an input of custom-task-1 (which sets a tag on it): const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - // For #3 Build: Read a different file (which is NOT an input of custom-task-1) - const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); - const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - if (!test2JS && testJS) { - // For #1 Build: - if (tag) { - console.log("Tag set by custom-task-1 is present in custom-task-2, as EXPECTED."); - } else { - throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); - } - } else { - // For #3 Build (SAME behavior expected as in #1): - if (tag) { - console.log("Tag set by custom-task-1 is present in custom-task-2, as EXPECTED."); - } else { - throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); - } + // For #1 & #3 build: + if (!tag) { + throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); } + + // For #3 build: Read a different file which is not an input of custom-task-1 + // (ensures that this task is executed): + const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); }; diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 0336439d9e1..d63787eeb59 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -258,7 +258,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) }); }); -// eslint-disable-next-line ava/no-skip-test -- tag handling to be implemented + test.serial.skip("Build application.a (multiple custom tasks)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; @@ -293,13 +293,14 @@ test.serial.skip("Build application.a (multiple custom tasks)", async (t) => { // Create a new file to allow a new build: // Logic of custom-task-1 will NOT handle this file, while custom-task-0 and 2 WILL DO it, // resulting in custom-task-1 getting skipped (cache reuse). - // The test should then verify that the tag is still set and now readable for custom-task-0 AND 2. + // The test should then verify that the tag is still only readable for custom-task-2. + // This ensures that the build result is exactly the same with or without using the cache. // (as in #1 build, the custom tasks already check for this tag by themselves and handle errors accordingly) await fs.cp(`${fixtureTester.fixturePath}/webapp/test.js`, `${fixtureTester.fixturePath}/webapp/test2.js`); // #3 build (with cache, with changes, with custom tasks) - // FIXME: Currently failing, because for custom-task-0 and 2 the tag is NOT set yet. + // FIXME: Currently failing, because for custom-task-2 the tag is NOT set yet. await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-multiple-customTasks.yaml"}, config: {destPath, cleanDest: true}, From 90c0a531da5e8a606f06518fea5773c142dd33ed Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 18 Feb 2026 16:15:02 +0100 Subject: [PATCH 153/223] test(project): Re-Add eslint rule (removed by accident) --- packages/project/test/lib/build/ProjectBuilder.integration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index d63787eeb59..114783c86f6 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -258,7 +258,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) }); }); - +// eslint-disable-next-line ava/no-skip-test -- tag handling to be implemented test.serial.skip("Build application.a (multiple custom tasks)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; From 6c0e0c3040bd3bd4566844a2f9d0183fb237b02a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 23 Feb 2026 13:57:12 +0100 Subject: [PATCH 154/223] refactor(fs): Add MonitoredResourceTagCollection New proxy class to record which resource tags are set by a task --- .../fs/lib/MonitoredResourceTagCollection.js | 77 +++++++++++++++++++ packages/fs/lib/ResourceTagCollection.js | 58 +++++++++++++- packages/fs/package.json | 3 +- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 packages/fs/lib/MonitoredResourceTagCollection.js diff --git a/packages/fs/lib/MonitoredResourceTagCollection.js b/packages/fs/lib/MonitoredResourceTagCollection.js new file mode 100644 index 00000000000..cbc84fca26f --- /dev/null +++ b/packages/fs/lib/MonitoredResourceTagCollection.js @@ -0,0 +1,77 @@ +/** + * Proxy of ResourceTagCollection + * + * @class + * @alias @ui5/fs/internal/MonitoredTagCollection + */ +class MonitoredTagCollection { + #tagCollection; + #tagOperations = new Map(); // resourcePath -> Map + + /** + * Constructor + * + * @param {object} tagCollection The ResourceTagCollection instance to wrap + */ + constructor(tagCollection) { + this.#tagCollection = tagCollection; + } + + /** + * Returns tags created or cleared via this MonitoredTagCollection during the execution of a task + * + * @returns {Map>} + * Map of resource paths to their tags that were set or cleared during this task's execution + */ + getTagOperations() { + return this.#tagOperations; + } + + /** + * Set a tag on a resource and track the operation + * + * @param {string|object} resourcePathOrResource Path of the resource or a resource instance + * @param {string} tag Tag in the format "namespace:Name" + * @param {string|number|boolean} [value=true] Tag value + */ + setTag(resourcePathOrResource, tag, value = true) { + this.#tagCollection.setTag(resourcePathOrResource, tag, value); + const resourcePath = this.#tagCollection._getPath(resourcePathOrResource); + + // Track tags set during this task's execution + if (!this.#tagOperations.has(resourcePath)) { + this.#tagOperations.set(resourcePath, new Map()); + } + this.#tagOperations.get(resourcePath).set(tag, value); + } + + /** + * Get a tag value from a resource + * + * @param {string|object} resourcePathOrResource Path of the resource or a resource instance + * @param {string} tag Tag in the format "namespace:Name" + * @returns {string|number|boolean|undefined} Tag value or undefined if not set + */ + getTag(resourcePathOrResource, tag) { + return this.#tagCollection.getTag(resourcePathOrResource, tag); + } + + /** + * Clear a tag from a resource and track the operation + * + * @param {string|object} resourcePathOrResource Path of the resource or a resource instance + * @param {string} tag Tag in the format "namespace:Name" + */ + clearTag(resourcePathOrResource, tag) { + this.#tagCollection.clearTag(resourcePathOrResource, tag); + const resourcePath = this.#tagCollection._getPath(resourcePathOrResource); + + // Track cleared tags during this task's execution + const resourceTags = this.#tagOperations.has(resourcePath); + if (resourceTags) { + resourceTags.set(tag, undefined); + } + } +} + +export default MonitoredTagCollection; diff --git a/packages/fs/lib/ResourceTagCollection.js b/packages/fs/lib/ResourceTagCollection.js index 9214c15bd0b..19232e9aa75 100644 --- a/packages/fs/lib/ResourceTagCollection.js +++ b/packages/fs/lib/ResourceTagCollection.js @@ -5,11 +5,18 @@ import ResourceFacade from "./ResourceFacade.js"; /** * A ResourceTagCollection * - * @private * @class * @alias @ui5/fs/internal/ResourceTagCollection */ class ResourceTagCollection { + /** + * Constructor + * + * @param {object} options Options + * @param {string[]} [options.allowedTags=[]] List of allowed tags + * @param {string[]} [options.allowedNamespaces=[]] List of allowed namespaces + * @param {object} [options.tags] Initial tags object mapping resource paths to their tags + */ constructor({allowedTags = [], allowedNamespaces = [], tags}) { this._allowedTags = allowedTags; // Allowed tags are validated during use this._allowedNamespaces = allowedNamespaces; @@ -32,6 +39,13 @@ class ResourceTagCollection { this._pathTags = tags || Object.create(null); } + /** + * Set a tag on a resource + * + * @param {string|object} resourcePath Path of the resource or a resource instance + * @param {string} tag Tag in the format "namespace:Name" + * @param {string|number|boolean} [value=true] Tag value + */ setTag(resourcePath, tag, value = true) { resourcePath = this._getPath(resourcePath); this._validateTag(tag); @@ -43,6 +57,12 @@ class ResourceTagCollection { this._pathTags[resourcePath][tag] = value; } + /** + * Clear a tag from a resource + * + * @param {string|object} resourcePath Path of the resource or a resource instance + * @param {string} tag Tag in the format "namespace:Name" + */ clearTag(resourcePath, tag) { resourcePath = this._getPath(resourcePath); this._validateTag(tag); @@ -52,6 +72,13 @@ class ResourceTagCollection { } } + /** + * Get a tag value from a resource + * + * @param {string|object} resourcePath Path of the resource or a resource instance + * @param {string} tag Tag in the format "namespace:Name" + * @returns {string|number|boolean|undefined} Tag value or undefined if not set + */ getTag(resourcePath, tag) { resourcePath = this._getPath(resourcePath); this._validateTag(tag); @@ -61,10 +88,21 @@ class ResourceTagCollection { } } + /** + * Get all tags for all resources + * + * @returns {object} Object mapping resource paths to their tags + */ getAllTags() { return this._pathTags; } + /** + * Check if a tag is accepted by this collection + * + * @param {string} tag Tag in the format "namespace:Name" + * @returns {boolean} Whether the tag is accepted + */ acceptsTag(tag) { if (this._allowedTags.includes(tag) || this._allowedNamespacesRegExp?.test(tag)) { return true; @@ -72,6 +110,12 @@ class ResourceTagCollection { return false; } + /** + * Extract the path from a resource or validate a path string + * + * @param {string|object} resourcePath Path of the resource or a resource instance + * @returns {string} Resolved resource path + */ _getPath(resourcePath) { if (typeof resourcePath !== "string") { if (resourcePath instanceof ResourceFacade) { @@ -86,6 +130,12 @@ class ResourceTagCollection { return resourcePath; } + /** + * Validate a tag format and check if it's accepted by this collection + * + * @param {string} tag Tag in the format "namespace:Name" + * @throws {Error} If the tag format is invalid or not accepted + */ _validateTag(tag) { if (!tag.includes(":")) { throw new Error(`Invalid Tag "${tag}": Colon required after namespace`); @@ -112,6 +162,12 @@ class ResourceTagCollection { } } + /** + * Validate that a tag value has an acceptable type + * + * @param {any} value Value to validate + * @throws {Error} If the value type is not string, number, or boolean + */ _validateValue(value) { const type = typeof value; if (!["string", "number", "boolean"].includes(type)) { diff --git a/packages/fs/package.json b/packages/fs/package.json index b763af8bbcb..4c8d1c05928 100644 --- a/packages/fs/package.json +++ b/packages/fs/package.json @@ -29,7 +29,8 @@ "./Resource": "./lib/Resource.js", "./resourceFactory": "./lib/resourceFactory.js", "./package.json": "./package.json", - "./internal/ResourceTagCollection": "./lib/ResourceTagCollection.js" + "./internal/ResourceTagCollection": "./lib/ResourceTagCollection.js", + "./internal/MonitoredResourceTagCollection": "./lib/MonitoredResourceTagCollection.js" }, "engines": { "node": "^22.20.0 || >=24.0.0", From e0890e4f2e55c92d205113d01f64c4344557382f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 23 Feb 2026 13:58:46 +0100 Subject: [PATCH 155/223] refactor(project): Add basic handling for resource tags * Add new class 'ProjectResources' * Add new ProjectBuildCache lifecycle hook "buildFinished" * This is used to clear build-tags which must not be visible to dependant projects (maybe we can find a better solution here) * ProjectBuilder must read the tags before triggering the hook Still missing: Integrate resource tags into hash tree for correct invalidation --- packages/project/lib/build/ProjectBuilder.js | 36 +- .../project/lib/build/cache/CacheManager.js | 2 +- .../lib/build/cache/ProjectBuildCache.js | 66 ++- .../project/lib/build/cache/StageCache.js | 9 +- .../lib/build/helpers/ProjectBuildContext.js | 23 +- .../project/lib/build/helpers/TaskUtil.js | 4 +- .../project/lib/resources/ProjectResources.js | 476 ++++++++++++++++++ packages/project/lib/resources/Stage.js | 45 ++ .../project/lib/specifications/Project.js | 282 ++--------- .../lib/build/ProjectBuilder.integration.js | 6 +- 10 files changed, 666 insertions(+), 283 deletions(-) create mode 100644 packages/project/lib/resources/ProjectResources.js create mode 100644 packages/project/lib/resources/Stage.js diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index a2bda654d89..b26ba6a5e6d 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -208,7 +208,7 @@ class ProjectBuilder { }); } const pWrites = []; - await this.#build(requestedProjects, (projectName, project, projectBuildContext) => { + await this.#build(requestedProjects, async (projectName, project, projectBuildContext) => { if (!fsTarget) { // Nothing to write to return; @@ -216,7 +216,7 @@ class ProjectBuilder { // Only write requested projects to target // (excluding dependencies that were required to be built, but not requested) this.#log.verbose(`Writing out files for project ${projectName}...`); - pWrites.push(this._writeResults(projectBuildContext, fsTarget)); + await this._writeResults(projectBuildContext, fsTarget, pWrites); }); await Promise.all(pWrites); } @@ -329,13 +329,15 @@ class ProjectBuilder { signal?.throwIfAborted(); if (projectBuiltCallback && requestedProjects.includes(projectName)) { - projectBuiltCallback(projectName, project, projectBuildContext); + await projectBuiltCallback(projectName, project, projectBuildContext); } if (!alreadyBuilt.includes(projectName) && !process.env.UI5_BUILD_NO_WRITE_CACHE) { this.#log.verbose(`Triggering cache update for project ${projectName}...`); pCacheWrites.push(projectBuildContext.writeBuildCache()); } + + projectBuildContext.buildFinished(); } this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`); } catch (err) { @@ -434,9 +436,10 @@ class ProjectBuilder { * * @param {object} projectBuildContext Build context for the project * @param {@ui5/fs/adapters/FileSystem} target Target adapter to write to + * @param {Array} deferredWork * @returns {Promise} Promise resolving when write is complete */ - async _writeResults(projectBuildContext, target) { + async _writeResults(projectBuildContext, target, deferredWork) { const project = projectBuildContext.getProject(); const taskUtil = projectBuildContext.getTaskUtil(); const buildConfig = this._buildContext.getBuildConfig(); @@ -472,7 +475,21 @@ class ProjectBuilder { })); } - await Promise.all(resources.map((resource) => { + const resourcesToWrite = resources.filter((resource) => { + if (taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult)) { + this.#log.silly(`Skipping resource tagged as "OmitFromBuildResult": ` + + resource.getPath()); + return false; // Skip this resource + } + return true; + }); + + deferredWork.push( + this._writeToDisk(resourcesToWrite, target, resources, taskUtil, project, isRootProject, outputStyle)); + } + + async _writeToDisk(resourcesToWrite, target, resources, taskUtil, project, isRootProject, outputStyle) { + await Promise.all(resourcesToWrite.map((resource) => { if (taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult)) { this.#log.silly(`Skipping write of resource tagged as "OmitFromBuildResult": ` + resource.getPath()); @@ -483,12 +500,14 @@ class ProjectBuilder { if (isRootProject && outputStyle === OutputStyleEnum.Flat && - project.getType() !== "application" /* application type is with a default flat build output structure */) { + /* application type is with a default flat build output structure */ + project.getType() !== "application") { const namespace = project.getNamespace(); const libraryResourcesPrefix = `/resources/${namespace}/`; const testResourcesPrefix = "/test-resources/"; const namespacedRegex = new RegExp(`/(resources|test-resources)/${namespace}`); - const processedResourcesSet = resources.reduce((acc, resource) => acc.add(resource.getPath()), new Set()); + const processedResourcesSet = resources.reduce( + (acc, resource) => acc.add(resource.getPath()), new Set()); // If outputStyle === "Flat", then the FlatReader would have filtered // some resources. We now need to get all of the available resources and @@ -507,7 +526,8 @@ class ProjectBuilder { skippedResources.forEach((resource) => { if (resource.originalPath.startsWith(testResourcesPrefix)) { this.#log.verbose( - `Omitting ${resource.originalPath} from build result. File is part of ${testResourcesPrefix}.` + `Omitting ${resource.originalPath} from build result. ` + + `File is part of ${testResourcesPrefix}.` ); } else if (!resource.originalPath.startsWith(libraryResourcesPrefix)) { this.#log.warn( diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 69c387a31d1..0647a62ade4 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -20,7 +20,7 @@ const chacheManagerInstances = new Map(); const CACACHE_OPTIONS = {algorithms: ["sha256"]}; // Cache version for compatibility management -const CACHE_VERSION = "v0_1"; +const CACHE_VERSION = "v0_2"; /** * Manages persistence for the build cache using file-based storage and cacache diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index db569902aed..c6e5eb401a8 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -36,6 +36,10 @@ export const RESULT_CACHE_STATES = Object.freeze({ * @property {string} signature Signature of the cached stage * @property {@ui5/fs/AbstractReader} stage Reader for the cached stage * @property {string[]} writtenResourcePaths Array of resource paths written by the task + * @property {Map>} projectTagOperations + * Map of resource paths to their tags that were set or cleared during this stage's execution, for project tags + * @property {Map>} buildTagOperations + * Map of resource paths to their tags that were set or cleared during this stage's execution, for build tags */ export default class ProjectBuildCache { @@ -116,6 +120,7 @@ export default class ProjectBuildCache { */ async prepareProjectBuildAndValidateCache(dependencyReader) { this.#currentProjectReader = this.#project.getReader(); + this.#currentDependencyReader = dependencyReader; if (this.#combinedIndexState === INDEX_STATES.INITIAL) { @@ -295,9 +300,9 @@ export default class ProjectBuildCache { */ async #importStages(stageSignatures) { const stageNames = Object.keys(stageSignatures); - if (this.#project.getStage()?.getId() === "initial") { + if (this.#project.getProjectResources().getStage()?.getId() === "initial") { // Only initialize stages once - this.#project.initStages(stageNames); + this.#project.getProjectResources().initStages(stageNames); } const importedStages = await Promise.all(stageNames.map(async (stageName) => { const stageSignature = stageSignatures[stageName]; @@ -308,13 +313,14 @@ export default class ProjectBuildCache { } return [stageName, stageCache]; })); - this.#project.useResultStage(); + this.#project.getProjectResources().useResultStage(); const writtenResourcePaths = new Set(); for (const [stageName, stageCache] of importedStages) { // Check whether the stage differs form the one currently in use if (this.#currentStageSignatures.get(stageName)?.join("-") !== stageCache.signature) { // Set stage - this.#project.setStage(stageName, stageCache.stage); + this.#project.getProjectResources().setStage(stageName, stageCache.stage, + stageCache.projectTagOperations, stageCache.buildTagOperations); // Store signature for later use in result stage signature calculation this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); @@ -387,7 +393,7 @@ export default class ProjectBuildCache { // Store current project reader (= state of the previous stage) for later use (e.g. in recordTaskResult) this.#currentProjectReader = this.#project.getReader(); // Switch project to new stage - this.#project.useStage(stageName); + this.#project.getProjectResources().useStage(stageName); log.verbose(`Preparing task execution for task ${taskName} in project ${this.#project.getName()}...`); if (!taskCache) { log.verbose(`No task cache found`); @@ -420,7 +426,8 @@ export default class ProjectBuildCache { const stageCache = await this.#findStageCache(stageName, stageSignatures); const oldStageSig = this.#currentStageSignatures.get(stageName)?.join("-"); if (stageCache) { - this.#project.setStage(stageName, stageCache.stage); + this.#project.getProjectResources().setStage(stageName, stageCache.stage, + stageCache.projectTagOperations, stageCache.buildTagOperations); // Check whether the stage actually changed if (stageCache.signature !== oldStageSig) { @@ -541,7 +548,7 @@ export default class ProjectBuildCache { return; } log.verbose(`Found cached stage with signature ${stageSignature}`); - const {resourceMapping, resourceMetadata} = stageMetadata; + const {resourceMapping, resourceMetadata, projectTagOperations, buildTagOperations} = stageMetadata; let writtenResourcePaths; let stageReader; if (resourceMapping) { @@ -570,10 +577,13 @@ export default class ProjectBuildCache { writtenResourcePaths = Object.keys(resourceMetadata); stageReader = this.#createReaderForStageCache(stageName, stageSignature, resourceMetadata); } + return { signature: stageSignature, stage: stageReader, writtenResourcePaths, + projectTagOperations: tagOpsToMap(projectTagOperations), + buildTagOperations: tagOpsToMap(buildTagOperations), }; })); return stageCache; @@ -610,10 +620,12 @@ export default class ProjectBuildCache { const taskCache = this.#taskCache.get(taskName); // Identify resources written by task - const stage = this.#project.getStage(); + const stage = this.#project.getProjectResources().getStage(); const stageWriter = stage.getWriter(); const writtenResources = await stageWriter.byGlob("/**/*"); const writtenResourcePaths = writtenResources.map((res) => res.getOriginalPath()); + const {projectTagOperations, buildTagOperations} = + this.#project.getProjectResources().getResourceTagOperations(); let stageSignature; if (cacheInfo) { @@ -654,8 +666,8 @@ export default class ProjectBuildCache { // Store resulting stage in stage cache this.#stageCache.addSignature( - this.#getStageNameForTask(taskName), stageSignature, this.#project.getStage(), - writtenResourcePaths); + this.#getStageNameForTask(taskName), stageSignature, this.#project.getProjectResources().getStage(), + writtenResourcePaths, projectTagOperations, buildTagOperations); // Update task cache with new metadata log.verbose(`Task ${taskName} produced ${writtenResourcePaths.length} resources`); @@ -733,7 +745,7 @@ export default class ProjectBuildCache { */ async setTasks(taskNames) { const stageNames = taskNames.map((taskName) => this.#getStageNameForTask(taskName)); - this.#project.initStages(stageNames); + this.#project.getProjectResources().initStages(stageNames); // TODO: Rename function? We simply use it to have a point in time right before the project is built } @@ -749,7 +761,7 @@ export default class ProjectBuildCache { * @returns {Promise} Array of changed resource paths since the last build */ async allTasksCompleted() { - this.#project.useResultStage(); + this.#project.getProjectResources().useResultStage(); if (this.#combinedIndexState === INDEX_STATES.INITIAL) { this.#combinedIndexState = INDEX_STATES.FRESH; } @@ -763,6 +775,10 @@ export default class ProjectBuildCache { return changedPaths; } + buildFinished() { + this.#project.getProjectResources().buildFinished(); + } + /** * Generates the stage name for a given task * @@ -947,7 +963,8 @@ export default class ProjectBuildCache { `with build signature ${this.#buildSignature}`); const stageQueue = this.#stageCache.flushCacheQueue(); await Promise.all(stageQueue.map(async ([stageId, stageSignature]) => { - const {stage} = this.#stageCache.getCacheForSignature(stageId, stageSignature); + const {stage, projectTagOperations, buildTagOperations} = + this.#stageCache.getCacheForSignature(stageId, stageSignature); const writer = stage.getWriter(); let metadata; @@ -974,7 +991,8 @@ export default class ProjectBuildCache { const resourceMetadata = await this.#writeStageResources(resources, stageId, stageSignature); metadata = {resourceMetadata}; } - + metadata.projectTagOperations = tagOpsToObject(projectTagOperations); + metadata.buildTagOperations = tagOpsToObject(buildTagOperations); await this.#cacheManager.writeStageCache( this.#project.getId(), this.#buildSignature, stageId, stageSignature, metadata); })); @@ -1183,3 +1201,23 @@ function createStageSignature(projectSignature, dependencySignature) { function createDependencySignature(stageDependencySignatures) { return crypto.createHash("sha256").update(stageDependencySignatures.join("")).digest("hex"); } + +function tagOpsToMap(tagOps) { + const map = new Map(); + for (const [resourcePath, tags] of Object.entries(tagOps)) { + map.set(resourcePath, new Map(Object.entries(tags))); + } + return map; +} + +/** + * @param {Map>} tagOps + * Map of resource paths to their tag operations + */ +function tagOpsToObject(tagOps) { + const obj = Object.create(null); + for (const [resourcePath, tags] of tagOps.entries()) { + obj[resourcePath] = Object.fromEntries(tags.entries()); + } + return obj; +} diff --git a/packages/project/lib/build/cache/StageCache.js b/packages/project/lib/build/cache/StageCache.js index b40b19dbff0..50699044b86 100644 --- a/packages/project/lib/build/cache/StageCache.js +++ b/packages/project/lib/build/cache/StageCache.js @@ -2,6 +2,8 @@ * @typedef {object} StageCacheEntry * @property {object} stage The cached stage instance (typically a reader or writer) * @property {string[]} writtenResourcePaths Array of resource paths written during stage execution + * @property {Map>} resourceTagOperations + * Map of resource paths to their tags that were set or cleared during this stage's execution */ /** @@ -40,8 +42,11 @@ export default class StageCache { * @param {string} signature Content hash signature of the stage's input resources * @param {object} stageInstance The stage instance to cache (typically a reader or writer) * @param {string[]} writtenResourcePaths Array of resource paths written during this stage + * @param {Map>} projectTagOperations + * @param {Map>} buildTagOperations + * Map of resource paths to their tags that were set or cleared during this stage's execution */ - addSignature(stageId, signature, stageInstance, writtenResourcePaths) { + addSignature(stageId, signature, stageInstance, writtenResourcePaths, projectTagOperations, buildTagOperations) { if (!this.#stageIdToSignatures.has(stageId)) { this.#stageIdToSignatures.set(stageId, new Map()); } @@ -50,6 +55,8 @@ export default class StageCache { signature, stage: stageInstance, writtenResourcePaths, + projectTagOperations, + buildTagOperations, }); this.#cacheQueue.push([stageId, signature]); } diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index f2ee7de57b9..0df6b74f854 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -1,4 +1,3 @@ -import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; import ProjectBuildLogger from "@ui5/logger/internal/loggers/ProjectBuild"; import TaskUtil from "./TaskUtil.js"; import TaskRunner from "../TaskRunner.js"; @@ -40,11 +39,6 @@ class ProjectBuildContext { this._queues = { cleanup: [] }; - - this._resourceTagCollection = new ResourceTagCollection({ - allowedTags: ["ui5:OmitFromBuildResult", "ui5:IsBundle"], - allowedNamespaces: ["build"] - }); } /** @@ -178,18 +172,8 @@ class ProjectBuildContext { if (!resource.hasProject()) { this._log.silly(`Associating resource ${resource.getPath()} with project ${this._project.getName()}`); resource.setProject(this._project); - // throw new Error( - // `Unable to get tag collection for resource ${resource.getPath()}: ` + - // `Resource must be associated to a project`); - } - const projectCollection = resource.getProject().getResourceTagCollection(); - if (projectCollection.acceptsTag(tag)) { - return projectCollection; - } - if (this._resourceTagCollection.acceptsTag(tag)) { - return this._resourceTagCollection; } - throw new Error(`Could not find collection for resource ${resource.getPath()} and tag ${tag}`); + return resource.getProject().getResourceTagCollection(resource, tag); } /** @@ -288,6 +272,11 @@ class ProjectBuildContext { // Propagate changed paths to dependents this.propagateResourceChanges(changedPaths); } + + buildFinished() { + this.getBuildCache().buildFinished(); + } + /** * Informs the build cache about changed project source resources * diff --git a/packages/project/lib/build/helpers/TaskUtil.js b/packages/project/lib/build/helpers/TaskUtil.js index b3a4fb97437..3a3f8e21d81 100644 --- a/packages/project/lib/build/helpers/TaskUtil.js +++ b/packages/project/lib/build/helpers/TaskUtil.js @@ -35,10 +35,10 @@ class TaskUtil { * This tag identifies resources that contain (i.e. bundle) multiple other resources * @property {string} IsDebugVariant * This tag identifies resources that are a debug variant (typically named with a "-dbg" suffix) - * of another resource. This tag is part of the build manifest. + * of another resource. This tag is visible to other projects * @property {string} HasDebugVariant * This tag identifies resources for which a debug variant has been created. - * This tag is part of the build manifest. + * This tag is visible to other projects */ /** diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js new file mode 100644 index 00000000000..fc1e983341a --- /dev/null +++ b/packages/project/lib/resources/ProjectResources.js @@ -0,0 +1,476 @@ +import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; +import MonitoredResourceTagCollection from "@ui5/fs/internal/MonitoredResourceTagCollection"; +import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; +import Stage from "./Stage.js"; + +const INITIAL_STAGE_ID = "initial"; +const RESULT_STAGE_ID = "result"; + +/** + * Manages resource access and stages for a project. + * + * @public + * @class + * @alias @ui5/project/resources/ProjectResources + */ +class ProjectResources { + #stages = []; // Stages in order of creation + + // State + #currentStage; + #currentStageReadIndex; + #lastTagCacheImportIndex; + #currentTagCacheImportIndex; + #currentStageId; + + // Cache + #currentStageWorkspace; + #currentStageReaders; // Map to store the various reader styles + + // Callbacks (interface object) + #getName; + #getStyledReader; + #createWriter; + #addReadersForWriter; + + // Project tag collection resets at the beginning of every build + #projectResourceTagCollection; + // Build tag collection resets at the end of every build + // (so that those tags are not accessible to dependent projects) + #buildResourceTagCollection; + + // Individual monitors per stage + #monitoredProjectResourceTagCollection; + #monitoredBuildResourceTagCollection; + + #buildManifest; + + /** + * @param {object} options Configuration options + * @param {Function} options.getName Returns the project name (for error messages and reader names) + * @param {Function} options.getStyledReader Gets the source reader for a given style + * @param {Function} options.createWriter Creates a writer for a stage + * @param {Function} options.addReadersForWriter Adds readers for a writer to a readers array + * @param {object} options.buildManifest + */ + constructor({getName, getStyledReader, createWriter, addReadersForWriter, buildManifest}) { + this.#getName = getName; + this.#getStyledReader = getStyledReader; + this.#createWriter = createWriter; + this.#addReadersForWriter = addReadersForWriter; + this.#buildManifest = buildManifest; + + this.#initStageMetadata(); + } + + /** + * Get a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for accessing all resources of the + * project in the specified "style": + * + *
    + *
  • buildtime: Resource paths are always prefixed with /resources/ + * or /test-resources/ followed by the project's namespace. + * Any configured build-excludes are applied
  • + *
  • dist: Resource paths always match with what the UI5 runtime expects. + * This means that paths generally depend on the project type. Applications for example use a "flat"-like + * structure, while libraries use a "buildtime"-like structure. + * Any configured build-excludes are applied
  • + *
  • runtime: Resource paths always match with what the UI5 runtime expects. + * This means that paths generally depend on the project type. Applications for example use a "flat"-like + * structure, while libraries use a "buildtime"-like structure. + * This style is typically used for serving resources directly. Therefore, build-excludes are not applied
  • + *
  • flat: Resource paths are never prefixed and namespaces are omitted if possible. Note that + * project types like "theme-library", which can have multiple namespaces, can't omit them. + * Any configured build-excludes are applied
  • + *
+ * + * If project resources have been changed through the means of a workspace, those changes + * are reflected in the provided reader too. + * + * Resource readers always use POSIX-style paths. + * + * @public + * @param {object} [options] + * @param {string} [options.style=buildtime] Path style to access resources. + * Can be "buildtime", "dist", "runtime" or "flat" + * @returns {@ui5/fs/ReaderCollection} A reader collection instance + */ + getReader({style = "buildtime"} = {}) { + let reader = this.#currentStageReaders.get(style); + if (reader) { + // Use cached reader + return reader; + } + + const readers = []; + if (this.#currentStage) { + // Add current writer as highest priority reader + const currentWriter = this.#currentStage.getWriter(); + if (currentWriter) { + this.#addReadersForWriter(readers, currentWriter, style); + } else { + const currentReader = this.#currentStage.getCachedWriter(); + if (currentReader) { + this.#addReadersForWriter(readers, currentReader, style); + } + } + } + // Add readers for previous stages and source + readers.push(...this.#getReaders(style)); + + reader = createReaderCollectionPrioritized({ + name: `Reader collection for stage '${this.#currentStageId}' of project ${this.#getName()}`, + readers + }); + + this.#currentStageReaders.set(style, reader); + return reader; + } + + #getReaders(style = "buildtime") { + const readers = []; + + // Add writers for previous stages as readers + const stageReadIdx = this.#currentStageReadIndex; + + // Collect writers from all relevant stages + for (let i = stageReadIdx; i >= 0; i--) { + this.#addReaderForStage(this.#stages[i], readers, style); + } + + // Finally add the project's source reader + readers.push(this.#getStyledReader(style)); + + return readers; + } + + /** + * Get the source reader for the project. + * + * @public + * @param {string} [style=buildtime] Path style to access resources + * @returns {@ui5/fs/ReaderCollection} A reader collection instance + */ + getSourceReader(style = "buildtime") { + return this.#getStyledReader(style); + } + + /** + * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a + * project's resources. This is always of style buildtime. + * + * Once a project has finished building, this method will throw to prevent further modifications + * since those would have no effect. Use the getReader method to access the project's (modified) resources + * + * @public + * @returns {@ui5/fs/DuplexCollection} DuplexCollection + */ + getWorkspace() { + if (this.#currentStageId === RESULT_STAGE_ID) { + throw new Error( + `Workspace of project ${this.#getName()} is currently not available. ` + + `This might indicate that the project has already finished building ` + + `and its content can not be modified further. ` + + `Use method 'getReader' for read-only access`); + } + if (this.#currentStageWorkspace) { + return this.#currentStageWorkspace; + } + const reader = createReaderCollectionPrioritized({ + name: `Reader collection for stage '${this.#currentStageId}' of project ${this.#getName()}`, + readers: this.#getReaders(), + }); + const writer = this.#currentStage.getWriter(); + const workspace = createWorkspace({ + reader, + writer + }); + this.#currentStageWorkspace = workspace; + return workspace; + } + + /** + * Seal the workspace of the project, preventing further modifications. + * This is typically called once the project has finished building. Resources from all stages will be used. + * + * A project can be unsealed by calling useStage() again. + * + * @public + */ + useResultStage() { + this.#currentStage = null; + this.#currentStageId = RESULT_STAGE_ID; + this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages + this.#currentTagCacheImportIndex = this.#stages.length - 1; // Import cached tags from all stages + + // Unset "current" reader/writer. They will be recreated on demand + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + + this.#monitoredProjectResourceTagCollection = null; + this.#monitoredBuildResourceTagCollection = null; + } + + #initStageMetadata() { + this.#stages = []; + // Initialize with an empty stage for use without stages (i.e. without build cache) + this.#currentStage = new Stage(INITIAL_STAGE_ID, this.#createWriter(INITIAL_STAGE_ID)); + this.#currentStageId = INITIAL_STAGE_ID; + this.#currentStageReadIndex = -1; + this.#lastTagCacheImportIndex = -1; + this.#currentTagCacheImportIndex = -1; + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + this.#projectResourceTagCollection = null; + } + + #addReaderForStage(stage, readers, style = "buildtime") { + const writer = stage.getWriter(); + if (writer) { + this.#addReadersForWriter(readers, writer, style); + } else { + const reader = stage.getCachedWriter(); + if (reader) { + this.#addReadersForWriter(readers, reader, style); + } + } + } + + /** + * Initialize stages for the build process. + * + * @public + * @param {string[]} stageIds Array of stage IDs to initialize + */ + initStages(stageIds) { + this.#initStageMetadata(); + for (let i = 0; i < stageIds.length; i++) { + const stageId = stageIds[i]; + const newStage = new Stage(stageId, this.#createWriter(stageId)); + this.#stages.push(newStage); + } + } + + /** + * Get the current stage. + * + * @public + * @returns {Stage|null} The current stage or null if in result stage + */ + getStage() { + return this.#currentStage; + } + + /** + * Switch to a specific stage. + * + * @public + * @param {string} stageId The ID of the stage to use + * @throws {Error} If the stage does not exist + */ + useStage(stageId) { + if (stageId === this.#currentStage?.getId()) { + // Already using requested stage + return; + } + + const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); + + if (stageIdx === -1) { + throw new Error(`Stage '${stageId}' does not exist in project ${this.#getName()}`); + } + + const stage = this.#stages[stageIdx]; + this.#currentStage = stage; + this.#currentStageId = stageId; + this.#currentStageReadIndex = stageIdx - 1; // Read from all previous stages + this.#currentTagCacheImportIndex = stageIdx; // Import cached tags from previous and current stages + + // Unset "current" reader/writer caches. They will be recreated on demand + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + + this.#monitoredProjectResourceTagCollection = null; + this.#monitoredBuildResourceTagCollection = null; + } + + /** + * Set or replace a stage. + * + * @public + * @param {string} stageId The ID of the stage to set + * @param {Stage|object} stageOrCachedWriter A Stage instance or a cached writer/reader + * @param {Map>} projectTagOperations + * @param {Map>} buildTagOperations + * @returns {boolean} True if the stored stage has changed, false otherwise + * @throws {Error} If the stage does not exist or invalid parameters are provided + */ + setStage(stageId, stageOrCachedWriter, projectTagOperations, buildTagOperations) { + const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); + if (stageIdx === -1) { + throw new Error(`Stage '${stageId}' does not exist in project ${this.#getName()}`); + } + if (!stageOrCachedWriter) { + throw new Error( + `Invalid stage or cache reader provided for stage '${stageId}' in project ${this.#getName()}`); + } + const oldStage = this.#stages[stageIdx]; + if (oldStage.getId() !== stageId) { + throw new Error( + `Stage ID mismatch for stage '${stageId}' in project ${this.#getName()}`); + } + let newStage; + if (stageOrCachedWriter instanceof Stage) { + newStage = stageOrCachedWriter; + if (oldStage === newStage) { + // Same stage as before, nothing to do + return false; // Stored stage has not changed + } + } else { + newStage = new Stage(stageId, undefined, stageOrCachedWriter, + projectTagOperations, buildTagOperations); + } + this.#stages[stageIdx] = newStage; + + // If we are updating the current stage, make sure to update and reset all relevant references + if (oldStage === this.#currentStage) { + this.#currentStage = newStage; + // Unset "current" reader/writer. They might be outdated + this.#currentStageReaders = new Map(); + this.#currentStageWorkspace = null; + } + return true; // Indicate that the stored stage has changed + } + + buildFinished() { + // Clear build resource tag collections. They must not be provided to dependent projects + this.#buildResourceTagCollection = null; + } + + /** + * Gets the appropriate resource tag collection for a resource and tag + * + * Determines which tag collection (project-specific or build-level) should be used + * for the given resource and tag combination. Associates the resource with the current + * project if not already associated. + * + * @param {@ui5/fs/Resource} resource Resource to get tag collection for + * @param {string} tag Tag to check acceptance for + * @returns {@ui5/fs/internal/ResourceTagCollection} Appropriate tag collection + * @throws {Error} If no collection accepts the given tag + */ + getResourceTagCollection(resource, tag) { + this.#applyCachedResourceTags(); + const projectCollection = this.#getProjectResourceTagCollection(); + if (projectCollection.acceptsTag(tag)) { + if (!this.#monitoredProjectResourceTagCollection) { + this.#monitoredProjectResourceTagCollection = new MonitoredResourceTagCollection(projectCollection); + } + return this.#monitoredProjectResourceTagCollection; + } + const buildCollection = this.#getBuildResourceTagCollection(); + if (buildCollection.acceptsTag(tag)) { + if (!this.#monitoredBuildResourceTagCollection) { + this.#monitoredBuildResourceTagCollection = new MonitoredResourceTagCollection(buildCollection); + } + return this.#monitoredBuildResourceTagCollection; + } + throw new Error(`Could not find collection for resource ${resource.getPath()} and tag ${tag}`); + } + + getResourceTagOperations() { + return { + projectTagOperations: new Map([ + ...this.#currentStage.getCachedProjectTagOperations() ?? [], + ...this.#monitoredProjectResourceTagCollection?.getTagOperations() ?? [], + ]), + buildTagOperations: new Map([ + ...this.#currentStage.getCachedBuildTagOperations() ?? [], + ...this.#monitoredBuildResourceTagCollection?.getTagOperations() ?? [], + ]), + }; + } + + #getProjectResourceTagCollection() { + if (!this.#projectResourceTagCollection) { + this.#projectResourceTagCollection = new ResourceTagCollection({ + allowedTags: ["ui5:IsDebugVariant", "ui5:HasDebugVariant"], + allowedNamespaces: ["project"], + tags: this.#buildManifest?.tags + }); + } + return this.#projectResourceTagCollection; + } + + #getBuildResourceTagCollection() { + if (!this.#buildResourceTagCollection) { + this.#buildResourceTagCollection = new ResourceTagCollection({ + allowedTags: ["ui5:OmitFromBuildResult", "ui5:IsBundle"], + allowedNamespaces: ["build"], + tags: this.#buildManifest?.tags + }); + } + return this.#buildResourceTagCollection; + } + + #applyCachedResourceTags() { + // Collect tag ops from all relevant stages + const cachedProjectTagOps = []; + const cachedBuildTagOps = []; + + for (let i = this.#lastTagCacheImportIndex + 1; i <= this.#currentTagCacheImportIndex; i++) { + const projectTagOps = this.#stages[i].getCachedProjectTagOperations(); + if (projectTagOps) { + cachedProjectTagOps.push(projectTagOps); + } + const buildTagOps = this.#stages[i].getCachedBuildTagOperations(); + if (buildTagOps) { + cachedBuildTagOps.push(buildTagOps); + } + } + + // if (this.#currentStage) { + // const projectTagOps = this.#currentStage.getCachedProjectTagOperations(); + // if (projectTagOps) { + // cachedProjectTagOps.push(projectTagOps); + // } + // const buildTagOps = this.#currentStage.getCachedBuildTagOperations(); + // if (buildTagOps) { + // cachedBuildTagOps.push(buildTagOps); + // } + // } + this.#lastTagCacheImportIndex = this.#currentTagCacheImportIndex; + + const projectTagOps = mergeMaps(...cachedProjectTagOps); + const buildTagOps = mergeMaps(...cachedBuildTagOps); + + if (projectTagOps.size) { + const projectTagCollection = this.#getProjectResourceTagCollection(); + for (const [resourcePath, tags] of projectTagOps.entries()) { + for (const [tag, value] of tags.entries()) { + projectTagCollection.setTag(resourcePath, tag, value); + } + } + } + if (buildTagOps.size) { + const buildTagCollection = this.#getBuildResourceTagCollection(); + for (const [resourcePath, tags] of buildTagOps.entries()) { + for (const [tag, value] of tags.entries()) { + buildTagCollection.setTag(resourcePath, tag, value); + } + } + } + } +} + +const mergeMaps = (...maps) => { + const result = new Map(); + for (const map of maps) { + for (const [key, value] of map) { + result.set(key, value); + } + } + return result; +}; + +export default ProjectResources; diff --git a/packages/project/lib/resources/Stage.js b/packages/project/lib/resources/Stage.js new file mode 100644 index 00000000000..e0e6d4276ed --- /dev/null +++ b/packages/project/lib/resources/Stage.js @@ -0,0 +1,45 @@ +/** + * A stage has either a writer or a reader, never both. + * Consumers need to be able to differentiate between the two + */ +class Stage { + #id; + #writer; + #cachedWriter; + #cachedProjectTagOperations; + #cachedBuildTagOperations; + + constructor(id, writer, cachedWriter, cachedProjectTagOperations, cachedBuildTagOperations) { + if (writer && cachedWriter) { + throw new Error( + `Stage '${id}' cannot have both a writer and a cache reader`); + } + this.#id = id; + this.#writer = writer; + this.#cachedWriter = cachedWriter; + this.#cachedProjectTagOperations = cachedProjectTagOperations; + this.#cachedBuildTagOperations = cachedBuildTagOperations; + } + + getId() { + return this.#id; + } + + getWriter() { + return this.#writer; + } + + getCachedWriter() { + return this.#cachedWriter; + } + + getCachedProjectTagOperations() { + return this.#cachedProjectTagOperations; + } + + getCachedBuildTagOperations() { + return this.#cachedBuildTagOperations; + } +} + +export default Stage; diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 4e66c6a9eae..0d80d9c3bdc 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -1,9 +1,5 @@ import Specification from "./Specification.js"; -import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; -import {createWorkspace, createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; - -const INITIAL_STAGE_ID = "initial"; -const RESULT_STAGE_ID = "result"; +import ProjectResources from "../resources/ProjectResources.js"; /** * Project @@ -16,16 +12,7 @@ const RESULT_STAGE_ID = "result"; * @hideconstructor */ class Project extends Specification { - #stages = []; // Stages in order of creation - - // State - #currentStage; - #currentStageReadIndex; - #currentStageId; - - // Cache - #currentStageWorkspace; - #currentStageReaders; // Map to store the various reader styles + #projectResources; constructor(parameters) { super(parameters); @@ -51,7 +38,15 @@ class Project extends Specification { await this._configureAndValidatePaths(this._config); await this._parseConfiguration(this._config, this._buildManifest); - this._initStageMetadata(); + + // Initialize ProjectResources with interface callbacks + this.#projectResources = new ProjectResources({ + getName: () => this.getName(), + getStyledReader: (style) => this._getStyledReader(style), + createWriter: (stageId) => this._createWriter(stageId), + addReadersForWriter: (readers, writer, style) => this._addReadersForWriter(readers, writer, style), + buildManifest: this._buildManifest + }); return this; } @@ -245,6 +240,16 @@ class Project extends Specification { /* === Resource Access === */ + /** + * Get the ProjectResources instance for this project. + * + * @public + * @returns {@ui5/project/resources/ProjectResources} The ProjectResources instance + */ + getProjectResources() { + return this.#projectResources; + } + /** * Get a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for accessing all resources of the * project in the specified "style": @@ -277,57 +282,19 @@ class Project extends Specification { * Can be "buildtime", "dist", "runtime" or "flat" * @returns {@ui5/fs/ReaderCollection} A reader collection instance */ - getReader({style = "buildtime"} = {}) { - let reader = this.#currentStageReaders.get(style); - if (reader) { - // Use cached reader - return reader; - } - - const readers = []; - if (this.#currentStage) { - // Add current writer as highest priority reader - const currentWriter = this.#currentStage.getWriter(); - if (currentWriter) { - this._addReadersForWriter(readers, currentWriter, style); - } else { - const currentReader = this.#currentStage.getCachedWriter(); - if (currentReader) { - this._addReadersForWriter(readers, currentReader, style); - } - } - } - // Add readers for previous stages and source - readers.push(...this.#getReaders(style)); - - reader = createReaderCollectionPrioritized({ - name: `Reader collection for stage '${this.#currentStageId}' of project ${this.getName()}`, - readers - }); - - this.#currentStageReaders.set(style, reader); - return reader; - } - - #getReaders(style = "buildtime") { - const readers = []; - - // Add writers for previous stages as readers - const stageReadIdx = this.#currentStageReadIndex; - - // Collect writers from all relevant stages - for (let i = stageReadIdx; i >= 0; i--) { - this.#addReaderForStage(this.#stages[i], readers, style); - } - - // Finally add the project's source reader - readers.push(this._getStyledReader(style)); - - return readers; + getReader(options) { + return this.#projectResources.getReader(options); } + /** + * Get the source reader for the project. + * + * @public + * @param {string} [style=buildtime] Path style to access resources + * @returns {@ui5/fs/ReaderCollection} A reader collection instance + */ getSourceReader(style = "buildtime") { - return this._getStyledReader(style); + return this.#projectResources.getSourceReader(style); } /** @@ -341,137 +308,7 @@ class Project extends Specification { * @returns {@ui5/fs/DuplexCollection} DuplexCollection */ getWorkspace() { - if (this.#currentStageId === RESULT_STAGE_ID) { - throw new Error( - `Workspace of project ${this.getName()} is currently not available. ` + - `This might indicate that the project has already finished building ` + - `and its content can not be modified further. ` + - `Use method 'getReader' for read-only access`); - } - if (this.#currentStageWorkspace) { - return this.#currentStageWorkspace; - } - const reader = createReaderCollectionPrioritized({ - name: `Reader collection for stage '${this.#currentStageId}' of project ${this.getName()}`, - readers: this.#getReaders(), - }); - const writer = this.#currentStage.getWriter(); - const workspace = createWorkspace({ - reader, - writer - }); - this.#currentStageWorkspace = workspace; - return workspace; - } - - /** - * Seal the workspace of the project, preventing further modifications. - * This is typically called once the project has finished building. Resources from all stages will be used. - * - * A project can be unsealed by calling useStage() again. - * - */ - useResultStage() { - this.#currentStage = null; - this.#currentStageId = RESULT_STAGE_ID; - this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages - - // Unset "current" reader/writer. They will be recreated on demand - this.#currentStageReaders = new Map(); - this.#currentStageWorkspace = null; - } - - _initStageMetadata() { - this.#stages = []; - // Initialize with an empty stage for use without stages (i.e. without build cache) - this.#currentStage = new Stage(INITIAL_STAGE_ID, this._createWriter(INITIAL_STAGE_ID)); - this.#currentStageId = INITIAL_STAGE_ID; - this.#currentStageReadIndex = -1; - this.#currentStageReaders = new Map(); - this.#currentStageWorkspace = null; - } - - #addReaderForStage(stage, readers, style = "buildtime") { - const writer = stage.getWriter(); - if (writer) { - this._addReadersForWriter(readers, writer, style); - } else { - const reader = stage.getCachedWriter(); - if (reader) { - this._addReadersForWriter(readers, reader, style); - } - } - } - - initStages(stageIds) { - this._initStageMetadata(); - for (let i = 0; i < stageIds.length; i++) { - const stageId = stageIds[i]; - const newStage = new Stage(stageId, this._createWriter(stageId)); - this.#stages.push(newStage); - } - } - - getStage() { - return this.#currentStage; - } - - useStage(stageId) { - if (stageId === this.#currentStage?.getId()) { - // Already using requested stage - return; - } - - const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); - - if (stageIdx === -1) { - throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); - } - - const stage = this.#stages[stageIdx]; - this.#currentStage = stage; - this.#currentStageId = stageId; - this.#currentStageReadIndex = stageIdx - 1; // Read from all previous stages - - // Unset "current" reader/writer caches. They will be recreated on demand - this.#currentStageReaders = new Map(); - this.#currentStageWorkspace = null; - } - - setStage(stageId, stageOrCachedWriter) { - const stageIdx = this.#stages.findIndex((s) => s.getId() === stageId); - if (stageIdx === -1) { - throw new Error(`Stage '${stageId}' does not exist in project ${this.getName()}`); - } - if (!stageOrCachedWriter) { - throw new Error( - `Invalid stage or cache reader provided for stage '${stageId}' in project ${this.getName()}`); - } - const oldStage = this.#stages[stageIdx]; - if (oldStage.getId() !== stageId) { - throw new Error( - `Stage ID mismatch for stage '${stageId}' in project ${this.getName()}`); - } - let newStage; - if (stageOrCachedWriter instanceof Stage) { - newStage = stageOrCachedWriter; - if (oldStage === newStage) { - // Same stage as before, nothing to do - return false; // Stored stage has not changed - } - } else { - newStage = new Stage(stageId, undefined, stageOrCachedWriter); - } - this.#stages[stageIdx] = newStage; - - // If we are updating the current stage, make sure to update and reset all relevant references - if (oldStage === this.#currentStage) { - this.#currentStage = newStage; - // Unset "current" reader/writer. They might be outdated - this.#currentStageReaders = new Map(); - this.#currentStageWorkspace = null; - } - return true; // Indicate that the stored stage has changed + return this.#projectResources.getWorkspace(); } /* Overwritten in ComponentProject subclass */ @@ -479,15 +316,20 @@ class Project extends Specification { readers.unshift(writer); } - getResourceTagCollection() { - if (!this._resourceTagCollection) { - this._resourceTagCollection = new ResourceTagCollection({ - allowedTags: ["ui5:IsDebugVariant", "ui5:HasDebugVariant"], - allowedNamespaces: ["project"], - tags: this.getBuildManifest()?.tags - }); - } - return this._resourceTagCollection; + /** + * Gets the appropriate resource tag collection for a resource and tag + * + * Determines which tag collection (project-specific or build-level) should be used + * for the given resource and tag combination. Associates the resource with the current + * project if not already associated. + * + * @param {@ui5/fs/Resource} resource Resource to get tag collection for + * @param {string} tag Tag to check acceptance for + * @returns {@ui5/fs/internal/ResourceTagCollection} Appropriate tag collection + * @throws {Error} If no collection accepts the given tag + */ + getResourceTagCollection(resource, tag) { + return this.#projectResources.getResourceTagCollection(resource, tag); } /* === Internals === */ @@ -504,36 +346,4 @@ class Project extends Specification { async _parseConfiguration(config) {} } -/** - * A stage has either a writer or a reader, never both. - * Consumers need to be able to differentiate between the two - */ -class Stage { - #id; - #writer; - #cachedWriter; - - constructor(id, writer, cachedWriter) { - if (writer && cachedWriter) { - throw new Error( - `Stage '${id}' cannot have both a writer and a cache reader`); - } - this.#id = id; - this.#writer = writer; - this.#cachedWriter = cachedWriter; - } - - getId() { - return this.#id; - } - - getWriter() { - return this.#writer; - } - - getCachedWriter() { - return this.#cachedWriter; - } -} - export default Project; diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 114783c86f6..510e7dd6ea0 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -188,8 +188,7 @@ test.serial("Build application.a project multiple times", async (t) => { }); }); -// eslint-disable-next-line ava/no-skip-test -- tag handling to be implemented -test.serial.skip("Build application.a (custom task and tag handling)", async (t) => { +test.serial("Build application.a (custom task and tag handling)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; @@ -258,8 +257,7 @@ test.serial.skip("Build application.a (custom task and tag handling)", async (t) }); }); -// eslint-disable-next-line ava/no-skip-test -- tag handling to be implemented -test.serial.skip("Build application.a (multiple custom tasks)", async (t) => { +test.serial("Build application.a (multiple custom tasks)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; From 59e4ed53a32d1c29f69b32b08b67114aeb74bde2 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 25 Feb 2026 14:11:01 +0100 Subject: [PATCH 156/223] test(project): Add another multiple-task / tag handling case --- .../custom-tasks-2/custom-task-0.js | 19 +++++++++ .../custom-tasks-2/custom-task-1.js | 25 +++++++++++ .../ui5-multiple-customTasks-2.yaml | 27 ++++++++++++ .../lib/build/ProjectBuilder.integration.js | 42 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js create mode 100644 packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js create mode 100644 packages/project/test/fixtures/application.a/ui5-multiple-customTasks-2.yaml diff --git a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js new file mode 100644 index 00000000000..c635e42e99e --- /dev/null +++ b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js @@ -0,0 +1,19 @@ +let buildRanOnce; +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + console.log("Custom task 0 executed"); + + // Read a file to trigger execution of this task: + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + + if (buildRanOnce != true) { + console.log("Flag NOT set -> We are in #1 Build still"); + buildRanOnce = true; + taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + } else { + console.log("Flag set -> We are in #2 Build"); + taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + } +}; diff --git a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js new file mode 100644 index 00000000000..4d112dbd981 --- /dev/null +++ b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js @@ -0,0 +1,25 @@ +let buildRanOnce; +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + console.log("Custom task 1 executed"); + + // Read a file to trigger execution of this task: + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + + if (buildRanOnce != true) { + console.log("Flag NOT set -> We are in #1 Build still"); + buildRanOnce = true; + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (!tag) { + throw new Error("Tag set during #1 Build is not readable, which is UNEXPECTED."); + } + } else { + console.log("Flag set -> We are in #2 Build"); + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + if (!tag) { + throw new Error("Tag set during #2 Build is not readable, which is UNEXPECTED."); + } + } +}; diff --git a/packages/project/test/fixtures/application.a/ui5-multiple-customTasks-2.yaml b/packages/project/test/fixtures/application.a/ui5-multiple-customTasks-2.yaml new file mode 100644 index 00000000000..0e8f71305b2 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-multiple-customTasks-2.yaml @@ -0,0 +1,27 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: custom-task-0 + afterTask: minify + - name: custom-task-1 + afterTask: custom-task-0 +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: custom-task-0 +task: + path: custom-tasks-2/custom-task-0.js +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: custom-task-1 +task: + path: custom-tasks-2/custom-task-1.js \ No newline at end of file diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 510e7dd6ea0..52b2c47755f 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -319,6 +319,48 @@ test.serial("Build application.a (multiple custom tasks)", async (t) => { }); }); +test.serial.skip("Build application.a (multiple custom tasks 2)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // This test should cover a scenario with multiple custom tasks. + // Specifically, it's invalidating the task cache by only modifying tags on resources, + // but not the resources themselves. + + // #1 build (no cache, no changes, with custom tasks) + // During this build, "custom-task-0" sets the tag "isDebugVariant" to test.js. + // "custom-task-1" checks if it's able to read this tag. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks-2.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + + // #2 build (with cache, no changes, with custom tasks) + // During this build, "custom-task-0" sets a different tag to test.js (namely "OmitFromBuildResult"). + // "custom-task-1" again checks if it's able to read this different tag. + // It's expected that both custom tasks are not getting skipped during this build, + // even though any resources weren't modified. + // FIXME: Currently, the entire build is skipped and therefore the custom tasks are not executed. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks-2.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} // TODO: add non-relevant skippedTasks here, once the tag handling works + } + } + }); + + // Check that test.js is omitted from build output: + await t.throwsAsync(fs.readFile(`${destPath}/test.js`, {encoding: "utf8"})); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; From 6ef07ac758bf4163b0b4b449c2e1f1cee15ba523 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 26 Feb 2026 17:59:19 +0100 Subject: [PATCH 157/223] test(project): Add case for dependency content changes This test should cover a scenario with an application depending on a library. Specifically, we're directly modifying the contents of the library which should have effects on the application because a custom task will detect it and modify the application's resources. The application is expected to get rebuilt. --- .../application.a/task.dependency-change.js | 36 ++++++++ .../ui5-customTask-dependency-change.yaml | 18 ++++ .../lib/build/ProjectBuilder.integration.js | 84 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/task.dependency-change.js create mode 100644 packages/project/test/fixtures/application.a/ui5-customTask-dependency-change.yaml diff --git a/packages/project/test/fixtures/application.a/task.dependency-change.js b/packages/project/test/fixtures/application.a/task.dependency-change.js new file mode 100644 index 00000000000..1a849d040ad --- /dev/null +++ b/packages/project/test/fixtures/application.a/task.dependency-change.js @@ -0,0 +1,36 @@ +// This is a modified version of the compileLicenseSummary example of the UI5 CLI. +// (https://github.com/UI5/cli/blob/b72919469d856508dd757ecf325a5fb45f15e56d/internal/documentation/docs/pages/extensibility/CustomTasks.md#example-libtaskscompilelicensesummaryjs) + +module.exports = async function ({dependencies, log, taskUtil, workspace, options: {projectNamespace}}) { + const {createResource} = taskUtil.resourceFactory; + const projectsVisited = new Set(); + + async function processProject(project) { + return Promise.all(taskUtil.getDependencies().map(async (projectName) => { + if (projectName !== "library.d") { + return; + } + if (projectsVisited.has(projectName)) { + return; + } + projectsVisited.add(projectName); + const project = taskUtil.getProject(projectName); + const newLibraryFile = await project.getReader().byGlob("**/newLibraryFile.js"); + if (newLibraryFile.length > 0) { + console.log('New Library file found. We are in #4 build.'); + // Change content of application.a: + const applicationResource = await workspace.byPath("/resources/id1/test.js"); + const content = (await applicationResource.getString()) + "\n console.log('something new');"; + await workspace.write(createResource({ + path: "/test.js", + string: content + })); + } else { + console.log(`New Library file not found. We are still in an earlier build.`); + } + return processProject(project); + })); + } + // Start processing dependencies of the root project + await processProject(taskUtil.getProject()); +}; diff --git a/packages/project/test/fixtures/application.a/ui5-customTask-dependency-change.yaml b/packages/project/test/fixtures/application.a/ui5-customTask-dependency-change.yaml new file mode 100644 index 00000000000..fa7743f34bf --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-customTask-dependency-change.yaml @@ -0,0 +1,18 @@ +--- +specVersion: "2.3" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: dependency-change + afterTask: minify +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: dependency-change +task: + path: task.dependency-change.js + diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 52b2c47755f..a40f776eca8 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -361,6 +361,90 @@ test.serial.skip("Build application.a (multiple custom tasks 2)", async (t) => { await t.throwsAsync(fs.readFile(`${destPath}/test.js`, {encoding: "utf8"})); }); +test.serial.skip("Build application.a (dependency content changes)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // This test should cover a scenario with an application depending on a library. + // Specifically, we're directly modifying the contents of the library + // which should have effects on the application because a custom task will detect it + // and modify the application's resources. The application is expected to get rebuilt. + + // #1 build (no cache, no changes, no dependencies) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-dependency-change.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + + // #2 build (with cache, no changes, no dependencies) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-dependency-change.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + + // Change content of library.d (this will not affect application.a): + const someJsOfLibrary = `${fixtureTester.fixturePath}/node_modules/library.d/main/src/library/d/some.js`; + await fs.appendFile(someJsOfLibrary, `\ntest("line added");\n`); + + // #3 build (with cache, with changes, with dependencies) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-dependency-change.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + } + } + }); + + // Check if library contains correct changed content: + const builtFileContent = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`test("line added");`), "Build dest contains changed file content"); + + + // Change content of library.d again (this time it affects application.a): + await fs.writeFile(`${fixtureTester.fixturePath}/node_modules/library.d/main/src/library/d/newLibraryFile.js`, + `console.log("SOME NEW CONTENT");`); + + // #4 build (no cache, with changes, with dependencies) + // This build should execute the custom task "task.dependency-change.js" again which now detects "newLibraryFile.js" + // and modifies a resource of application.a (namely "test.js"). + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-dependency-change.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "application.a": { // FIXME: currently failing (getting skipped entirely) + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + }, + } + } + }); + + // Check that application.a contains correct changed content (test.js): + const builtFileContent2 = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true(builtFileContent2.includes(`console.log('something new');`), "Build dest contains changed file content"); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; From 627d4234ba22976d6cda17cccd5bc82830a604fc Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Fri, 27 Feb 2026 13:52:55 +0100 Subject: [PATCH 158/223] test(project): Clean-up temporary comments This removes the FIXMEs which are fixed with a516158917dd566e9a6eb88a4025673073a78866 --- packages/project/test/lib/build/ProjectBuilder.integration.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index a40f776eca8..50e61f39ae8 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -240,7 +240,7 @@ test.serial("Build application.a (custom task and tag handling)", async (t) => { } }); - // Check that fileToBeOmitted.js is not in dist again --> FIXME: Currently failing here + // Check that fileToBeOmitted.js is not in dist again await t.throwsAsync(fs.readFile(`${destPath}/fileToBeOmitted.js`, {encoding: "utf8"})); @@ -298,7 +298,6 @@ test.serial("Build application.a (multiple custom tasks)", async (t) => { `${fixtureTester.fixturePath}/webapp/test2.js`); // #3 build (with cache, with changes, with custom tasks) - // FIXME: Currently failing, because for custom-task-2 the tag is NOT set yet. await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-multiple-customTasks.yaml"}, config: {destPath, cleanDest: true}, From f9b97cd261d6839cc11791051d9738540cad1303 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 5 Mar 2026 15:11:32 +0100 Subject: [PATCH 159/223] test(project): Add case for JSDoc builds (Standard Tasks) --- .../lib/build/ProjectBuilder.integration.js | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 50e61f39ae8..e9ca7c66386 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -444,6 +444,123 @@ test.serial.skip("Build application.a (dependency content changes)", async (t) = t.true(builtFileContent2.includes(`console.log('something new');`), "Build dest contains changed file content"); }); +test.serial("Build application.a (JSDoc build)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // This test should cover a scenario with an application depending on a library. + // We're executing a JSDoc build including dependencies (as with "ui5 build jsdoc --all") + // and testing if the output contains the expected JSDoc contents. + // Then, we're adding some additional JSDoc annotations to the library + // and testing the same again. + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, jsdoc: "jsdoc", dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // Check that JSDoc build ran successfully: + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/some-dbg.js`, {encoding: "utf8"})); + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/some.js.map`, {encoding: "utf8"})); + const builtFileContent = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + // Check that output contains correct file content: + t.false(builtFileContent.includes(`//# sourceMappingURL=some.js.map`), + "Build dest does not contain source map reference"); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, jsdoc: "jsdoc", dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + + // Add additional JSDoc annotations to library.d: + const jsdocContent = `/*! +* ` + "${copyright}" + ` +*/ + +/** +* Example JSDoc annotation +* +* @public +* @static +* @param {object} param +* @returns {string} output +*/ +function functionWithJSDoc(param) {return "test"}`; + + await fs.writeFile(`${fixtureTester.fixturePath}/node_modules/library.d/main/src/library/d/some.js`, + jsdocContent); + + // #3 build (no cache, with changes) + // application.a should get skipped: + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, jsdoc: "jsdoc", dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": { + skippedTasks: [ + "buildThemes", + "enhanceManifest", + "escapeNonAsciiCharacters", + "executeJsdocSdkTransformation", + ] + } + } + } + }); + + // Check that JSDoc build ran successfully: + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/some-dbg.js`, {encoding: "utf8"})); + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/some.js.map`, {encoding: "utf8"})); + const builtFileContent2 = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + t.true(builtFileContent2.includes(`Example JSDoc annotation`), "Build dest contains new JSDoc content"); + // Check that output contains new file content: + t.false(builtFileContent2.includes(`//# sourceMappingURL=some.js.map`), + "Build dest does not contain source map reference"); + + + // #4 build (no cache, no changes) + // Normal build again (non-JSDoc build); should not execute task "generateJsdoc": + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // Check that normal build ran successfully: + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/d/some-dbg.js`, {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/d/some.js.map`, {encoding: "utf8"})); + const builtFileContent3 = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + t.false(builtFileContent3.includes(`Example JSDoc annotation`), "Build dest doesn't contain JSDoc content anymore"); + // Check that output contains content generated by the normal build: + t.true(builtFileContent3.includes(`//# sourceMappingURL=some.js.map`), + "Build dest does contain source map reference"); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; From 0031e0478c27483e1c79c3af0a13e5dd6b411447 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 5 Mar 2026 15:36:16 +0100 Subject: [PATCH 160/223] test(project): Address review of @RandomByte --- .../application.a/custom-tasks-2/custom-task-0.js | 9 ++++++--- .../application.a/custom-tasks-2/custom-task-1.js | 13 ++++++++++--- .../application.a/task.dependency-change.js | 10 +++++----- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js index c635e42e99e..78495298e3a 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js +++ b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js @@ -1,19 +1,22 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:customTask0"); + let buildRanOnce; module.exports = async function ({ workspace, taskUtil, options: {projectNamespace} }) { - console.log("Custom task 0 executed"); + log.verbose("Custom task 0 executed"); // Read a file to trigger execution of this task: const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); if (buildRanOnce != true) { - console.log("Flag NOT set -> We are in #1 Build still"); + log.verbose("Flag NOT set -> We are in #1 Build still"); buildRanOnce = true; taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); } else { - console.log("Flag set -> We are in #2 Build"); + log.verbose("Flag set -> We are in #2 Build"); taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); } }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js index 4d112dbd981..98f8b94c848 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js +++ b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js @@ -1,22 +1,29 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:customTask1"); + let buildRanOnce; module.exports = async function ({ workspace, taskUtil, options: {projectNamespace} }) { - console.log("Custom task 1 executed"); + log.verbose("Custom task 1 executed"); // Read a file to trigger execution of this task: const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); if (buildRanOnce != true) { - console.log("Flag NOT set -> We are in #1 Build still"); + log.verbose("Flag NOT set -> We are in #1 Build still"); buildRanOnce = true; const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); if (!tag) { throw new Error("Tag set during #1 Build is not readable, which is UNEXPECTED."); } } else { - console.log("Flag set -> We are in #2 Build"); + const previousTag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (previousTag) { + throw new Error("Tag set during #1 Build is still readable, which is UNEXPECTED."); + } + log.verbose("Flag set -> We are in #2 Build"); const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); if (!tag) { throw new Error("Tag set during #2 Build is not readable, which is UNEXPECTED."); diff --git a/packages/project/test/fixtures/application.a/task.dependency-change.js b/packages/project/test/fixtures/application.a/task.dependency-change.js index 1a849d040ad..118e74b1cb1 100644 --- a/packages/project/test/fixtures/application.a/task.dependency-change.js +++ b/packages/project/test/fixtures/application.a/task.dependency-change.js @@ -1,11 +1,11 @@ -// This is a modified version of the compileLicenseSummary example of the UI5 CLI. +// This is a modified version of the compileLicenseSummary example of the UI5 CLI documentation. // (https://github.com/UI5/cli/blob/b72919469d856508dd757ecf325a5fb45f15e56d/internal/documentation/docs/pages/extensibility/CustomTasks.md#example-libtaskscompilelicensesummaryjs) -module.exports = async function ({dependencies, log, taskUtil, workspace, options: {projectNamespace}}) { +module.exports = async function ({log, taskUtil, workspace}) { const {createResource} = taskUtil.resourceFactory; const projectsVisited = new Set(); - async function processProject(project) { + async function processProject() { return Promise.all(taskUtil.getDependencies().map(async (projectName) => { if (projectName !== "library.d") { return; @@ -17,7 +17,7 @@ module.exports = async function ({dependencies, log, taskUtil, workspace, option const project = taskUtil.getProject(projectName); const newLibraryFile = await project.getReader().byGlob("**/newLibraryFile.js"); if (newLibraryFile.length > 0) { - console.log('New Library file found. We are in #4 build.'); + log.verbose('New Library file found. We are in #4 build.'); // Change content of application.a: const applicationResource = await workspace.byPath("/resources/id1/test.js"); const content = (await applicationResource.getString()) + "\n console.log('something new');"; @@ -26,7 +26,7 @@ module.exports = async function ({dependencies, log, taskUtil, workspace, option string: content })); } else { - console.log(`New Library file not found. We are still in an earlier build.`); + log.verbose(`New Library file not found. We are still in an earlier build.`); } return processProject(project); })); From dbe8dbfc26088dfdbdd9e1157b6ef6d5e5979829 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 5 Mar 2026 15:13:14 +0100 Subject: [PATCH 161/223] refactor(project): Enhance build cache logging for signatures --- .../project/lib/build/cache/ProjectBuildCache.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index c6e5eb401a8..c242240e19b 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -64,7 +64,6 @@ export default class ProjectBuildCache { #combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES; #resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; - // #dependencyIndicesInitialized = false; /** * Creates a new ProjectBuildCache instance @@ -547,7 +546,7 @@ export default class ProjectBuildCache { if (!stageMetadata) { return; } - log.verbose(`Found cached stage with signature ${stageSignature}`); + log.verbose(`Found cached stage for task ${stageName} with signature ${stageSignature}`); const {resourceMapping, resourceMetadata, projectTagOperations, buildTagOperations} = stageMetadata; let writtenResourcePaths; let stageReader; @@ -854,6 +853,9 @@ export default class ProjectBuildCache { this.#sourceIndex = await ResourceIndex.create(resources, Date.now()); this.#combinedIndexState = INDEX_STATES.INITIAL; } + log.verbose( + `Initialized source index for project ${this.#project.getName()} ` + + `with signature ${this.#sourceIndex.getSignature()}`); } /** @@ -880,7 +882,8 @@ export default class ProjectBuildCache { if (removed.length || added.length || updated.length) { log.verbose(`Source resource index for project ${this.#project.getName()} updated: ` + - `${removed.length} removed, ${added.length} added, ${updated.length} updated resources.`); + `${removed.length} removed, ${added.length} added, ${updated.length} updated resources. ` + + `New signature: ${this.#sourceIndex.getSignature()}`); const changedPaths = [...removed, ...added, ...updated]; // Since all source files are part of the result, declare any detected changes as newly written resources for (const resourcePath of changedPaths) { @@ -936,7 +939,8 @@ export default class ProjectBuildCache { // No changes to already cached result stage return; } - log.verbose(`Storing result metadata for project ${this.#project.getName()}`); + log.verbose(`Storing result metadata for project ${this.#project.getName()} ` + + `using result stage signature ${stageSignature}`); const stageSignatures = Object.create(null); for (const [stageName, stageSigs] of this.#currentStageSignatures.entries()) { stageSignatures[stageName] = stageSigs.join("-"); From e4a8f7cc14e946cffd60cfdca7374c8019b1afa2 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 5 Mar 2026 15:13:31 +0100 Subject: [PATCH 162/223] test(project): Add basic library build test for BuildServer --- .../test/lib/build/BuildServer.integration.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 5bc6660bd99..f8fcb3023af 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -196,6 +196,88 @@ test.serial("Serve application.a, request library resource", async (t) => { ); }); +test.serial.skip("Serve library", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "library.d"); + + // #1 request with empty cache + await fixtureTester.serveProject({ + config: { + excludedTasks: ["minify"], + } + }); + await fixtureTester.requestResource({ + resource: "/resources/library/d/some.js", + assertions: { + projects: { + "library.d": {} + } + } + }); + + // #2 request with cache + await fixtureTester.requestResource({ + resource: "/resources/library/d/some.js", + assertions: { + projects: {} + } + }); + + // Change a source file in library.d + const changedFilePath = `${fixtureTester.fixturePath}/main/src/library/d/some.js`; + const originalContent = await fs.readFile(changedFilePath, {encoding: "utf8"}); + await fs.writeFile( + changedFilePath, + originalContent.replace( + ` */`, + ` */\n// Test 1` + ) + ); + + await setTimeout(500); // Wait for the file watcher to detect and propagate the change + + // #3 request with cache and changes + const resourceContent1 = await fixtureTester.requestResource({ + resource: "/resources/library/d/some.js", + assertions: { + projects: { + "library.d": { + skippedTasks: [ + "buildThemes", + "enhanceManifest", + "escapeNonAsciiCharacters", + "replaceBuildtime", + ] + } + } + } + }); + + // Check whether the changed file is served + const servedFileContent1 = await resourceContent1.getString(); + t.true( + servedFileContent1.includes(`Test 1`), + "Resource contains changed file content" + ); + + // Restore original file content + + await fs.writeFile(changedFilePath, originalContent); + + // #4 request with cache (no changes) + const resourceContent2 = await fixtureTester.requestResource({ + resource: "/resources/library/d/some.js", + assertions: { + projects: {} + } + }); + + const servedFileContent2 = await resourceContent2.getString(); + t.false( + servedFileContent2.includes(`Test 1`), + "Resource does not contain changed file content" + ); +}); + test.serial("Serve application.a, request application resource AND library resource", async (t) => { const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); From 2c6e830b77e0a3aeb5fd1c0dd0d6fc565e573946 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 5 Mar 2026 16:35:28 +0100 Subject: [PATCH 163/223] test(project): Add cases for custom preload configs (for application, component & library) --- .../ui5-custom-preload-config.yaml | 11 ++ .../ui5-custom-preload-config.yaml | 11 ++ .../library.d/ui5-custom-preload-config.yaml | 15 +++ .../lib/build/ProjectBuilder.integration.js | 105 ++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/ui5-custom-preload-config.yaml create mode 100644 packages/project/test/fixtures/component.a/ui5-custom-preload-config.yaml create mode 100644 packages/project/test/fixtures/library.d/ui5-custom-preload-config.yaml diff --git a/packages/project/test/fixtures/application.a/ui5-custom-preload-config.yaml b/packages/project/test/fixtures/application.a/ui5-custom-preload-config.yaml new file mode 100644 index 00000000000..41a5a497fe4 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-custom-preload-config.yaml @@ -0,0 +1,11 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + componentPreload: + namespaces: + - "id1" + excludes: + - "id1/thirdparty/scriptWithSourceMap.js" \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/ui5-custom-preload-config.yaml b/packages/project/test/fixtures/component.a/ui5-custom-preload-config.yaml new file mode 100644 index 00000000000..9d1ef25beca --- /dev/null +++ b/packages/project/test/fixtures/component.a/ui5-custom-preload-config.yaml @@ -0,0 +1,11 @@ +--- +specVersion: "5.0" +type: component +metadata: + name: component.a +builder: + componentPreload: + namespaces: + - "id1" + excludes: + - "id1/test.js" \ No newline at end of file diff --git a/packages/project/test/fixtures/library.d/ui5-custom-preload-config.yaml b/packages/project/test/fixtures/library.d/ui5-custom-preload-config.yaml new file mode 100644 index 00000000000..6187b116068 --- /dev/null +++ b/packages/project/test/fixtures/library.d/ui5-custom-preload-config.yaml @@ -0,0 +1,15 @@ +--- +specVersion: "5.0" +type: library +metadata: + name: library.d + copyright: Some fancy copyright +resources: + configuration: + paths: + src: main/src + test: main/test +builder: + libraryPreload: + excludes: + - "library/d/some.js" \ No newline at end of file diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index e9ca7c66386..8d27c8679fd 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -561,6 +561,41 @@ function functionWithJSDoc(param) {return "test"}`; "Build dest does contain source map reference"); }); +test.serial("Build application.a (Custom Component preload configuration)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // In this test, we're testing the behavior of a custom preload configuration + // which is defined in "ui5-custom-preload-config.yaml". + // This custom preload configuration generates a Component-preload.js similar to a default one. + // However, it will omit a resource ("scriptWithSourceMap.js") from the bundle. + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Check that generated preload bundle doesn't contain the omitted file: + t.false((await fs.readFile(`${destPath}/Component-preload.js`, {encoding: "utf8"})) + .includes("id1/thirdparty/scriptWithSourceMap.js")); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; @@ -651,6 +686,41 @@ test.serial("Build library.d project multiple times", async (t) => { }); }); +test.serial("Build library.d (Custom Library preload configuration)", async (t) => { + const fixtureTester = new FixtureTester(t, "library.d"); + const destPath = fixtureTester.destPath; + + // In this test, we're testing the behavior of a custom preload configuration + // which is defined in "ui5-custom-preload-config.yaml". + // This custom preload configuration generates a library-preload.js similar to a default one. + // However, it will omit a resource ("some.js") from the bundle. + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "library.d": {} + } + } + }); + + // Check that generated preload bundle doesn't contain the omitted file: + t.false((await fs.readFile(`${destPath}/resources/library/d/library-preload.js`, {encoding: "utf8"})) + .includes("library/d/some.js")); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); +}); + test.serial("Build theme.library.e project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "theme.library.e"); const destPath = fixtureTester.destPath; @@ -886,6 +956,41 @@ test.serial("Build component.a project multiple times", async (t) => { }); }); +test.serial("Build component.a (Custom Component preload configuration)", async (t) => { + const fixtureTester = new FixtureTester(t, "component.a"); + const destPath = fixtureTester.destPath; + + // In this test, we're testing the behavior of a custom preload configuration + // which is defined in "ui5-custom-preload-config.yaml". + // This custom preload configuration generates a Component-preload.js similar to a default one. + // However, it will omit a resource ("test.js") from the bundle. + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "component.a": {} + } + } + }); + + // Check that generated preload bundle doesn't contain the omitted file: + t.false((await fs.readFile(`${destPath}/resources/id1/Component-preload.js`, {encoding: "utf8"})) + .includes("id1/test.js")); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); +}); + test.serial("Build module.b project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "module.b"); const destPath = fixtureTester.destPath; From cc2c7feeda243ec6f616393809d9a640b288c946 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 6 Mar 2026 14:39:39 +0100 Subject: [PATCH 164/223] refactor(project): Adapt tests after refactoring of resource tag handling --- packages/project/lib/build/ProjectBuilder.js | 9 +-- .../lib/build/helpers/createBuildManifest.js | 2 +- .../project/lib/resources/ProjectResources.js | 13 ++++ .../project/lib/specifications/Project.js | 9 +++ .../project/test/lib/build/ProjectBuilder.js | 24 +++++--- .../test/lib/build/cache/ProjectBuildCache.js | 33 ++++++---- .../lib/build/helpers/ProjectBuildContext.js | 60 ++++--------------- .../createBuildManifest.integration.js | 8 +-- .../lib/build/helpers/createBuildManifest.js | 4 +- 9 files changed, 81 insertions(+), 81 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index b26ba6a5e6d..4af522746ff 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -485,16 +485,11 @@ class ProjectBuilder { }); deferredWork.push( - this._writeToDisk(resourcesToWrite, target, resources, taskUtil, project, isRootProject, outputStyle)); + this._writeToDisk(resourcesToWrite, target, resources, project, isRootProject, outputStyle)); } - async _writeToDisk(resourcesToWrite, target, resources, taskUtil, project, isRootProject, outputStyle) { + async _writeToDisk(resourcesToWrite, target, resources, project, isRootProject, outputStyle) { await Promise.all(resourcesToWrite.map((resource) => { - if (taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult)) { - this.#log.silly(`Skipping write of resource tagged as "OmitFromBuildResult": ` + - resource.getPath()); - return; // Skip target write for this resource - } return target.write(resource); })); diff --git a/packages/project/lib/build/helpers/createBuildManifest.js b/packages/project/lib/build/helpers/createBuildManifest.js index 7ab4d4244da..1fe44ca7f40 100644 --- a/packages/project/lib/build/helpers/createBuildManifest.js +++ b/packages/project/lib/build/helpers/createBuildManifest.js @@ -8,7 +8,7 @@ async function getVersion(pkg) { } function getSortedTags(project) { - const tags = project.getResourceTagCollection().getAllTags(); + const tags = project.getProjectResourceTagCollection().getAllTags(); const entities = Object.entries(tags); entities.sort(([keyA], [keyB]) => { return keyA.localeCompare(keyB); diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index fc1e983341a..319db848c3e 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -391,6 +391,19 @@ class ProjectResources { }; } + /** + * Returns the project-level resource tag collection. + * + * This provides direct access to the collection holding project-level tags + * (e.g. ui5:IsDebugVariant, ui5:HasDebugVariant), which is needed for + * build manifest creation and reading. + * + * @returns {@ui5/fs/internal/ResourceTagCollection} The project-level resource tag collection + */ + getProjectResourceTagCollection() { + return this.#getProjectResourceTagCollection(); + } + #getProjectResourceTagCollection() { if (!this.#projectResourceTagCollection) { this.#projectResourceTagCollection = new ResourceTagCollection({ diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 0d80d9c3bdc..97c6703231a 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -332,6 +332,15 @@ class Project extends Specification { return this.#projectResources.getResourceTagCollection(resource, tag); } + /** + * Returns the project-level resource tag collection + * + * @returns {@ui5/fs/internal/ResourceTagCollection} The project-level resource tag collection + */ + getProjectResourceTagCollection() { + return this.#projectResources.getProjectResourceTagCollection(); + } + /* === Internals === */ /** * @private diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index 116363a8d50..d1255d8224b 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -122,7 +122,8 @@ test("build", async (t) => { buildProject: buildProjectStub, writeBuildCache: writeBuildCacheStub, requiresBuild: requiresBuildStub, - getProject: sinon.stub().returns(getMockProject("library")) + getProject: sinon.stub().returns(getMockProject("library")), + buildFinished: sinon.stub() }; const getRequiredProjectContextsStub = sinon.stub(builder._buildContext, "getRequiredProjectContexts") .resolves(new Map().set("project.a", projectBuildContextMock)); @@ -295,21 +296,24 @@ test.serial("build: Multiple projects", async (t) => { prepareProjectBuildAndValidateCache: sinon.stub().resolves(false), buildProject: buildProjectAStub, writeBuildCache: writeBuildCacheStub, - getProject: sinon.stub().returns(getMockProject("library", "a")) + getProject: sinon.stub().returns(getMockProject("library", "a")), + buildFinished: sinon.stub() }; const projectBuildContextMockB = { possiblyRequiresBuild: sinon.stub().returns(false), prepareProjectBuildAndValidateCache: sinon.stub().resolves(false), buildProject: buildProjectBStub, writeBuildCache: writeBuildCacheStub, - getProject: sinon.stub().returns(getMockProject("library", "b")) + getProject: sinon.stub().returns(getMockProject("library", "b")), + buildFinished: sinon.stub() }; const projectBuildContextMockC = { possiblyRequiresBuild: sinon.stub().returns(true), prepareProjectBuildAndValidateCache: sinon.stub().resolves(false), buildProject: buildProjectCStub, writeBuildCache: writeBuildCacheStub, - getProject: sinon.stub().returns(getMockProject("library", "c")) + getProject: sinon.stub().returns(getMockProject("library", "c")), + buildFinished: sinon.stub() }; const getRequiredProjectContextsStub = sinon.stub(builder._buildContext, "getRequiredProjectContexts") .resolves(new Map() @@ -492,7 +496,9 @@ test("_writeResults", async (t) => { write: sinon.stub().resolves() }; - await builder._writeResults(projectBuildContextMock, writerMock); + const deferredWork = []; + await builder._writeResults(projectBuildContextMock, writerMock, deferredWork); + await Promise.all(deferredWork); t.is(getReaderStub.callCount, 1, "One reader requested"); t.deepEqual(getReaderStub.getCall(0).args[0], { @@ -572,7 +578,9 @@ test.serial("_writeResults: Create build manifest", async (t) => { write: sinon.stub().resolves() }; - await builder._writeResults(projectBuildContextMock, writerMock); + const deferredWork = []; + await builder._writeResults(projectBuildContextMock, writerMock, deferredWork); + await Promise.all(deferredWork); t.is(getReaderStub.callCount, 1, "One reader requested"); t.deepEqual(getReaderStub.getCall(0).args[0], { @@ -670,7 +678,9 @@ test.serial("_writeResults: Flat build output", async (t) => { write: sinon.stub().resolves() }; - await builder._writeResults(projectBuildContextMock, writerMock); + const deferredWork = []; + await builder._writeResults(projectBuildContextMock, writerMock, deferredWork); + await Promise.all(deferredWork); t.is(getReaderStub.callCount, 2, "One reader requested"); t.deepEqual(getReaderStub.getCall(0).args[0], { diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index ff3bfa4825c..93f05251069 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -14,11 +14,7 @@ function createMockProject(name = "test.project", id = "test-project-id") { byPath: sinon.stub().resolves(null) }); - return { - getName: () => name, - getId: () => id, - getSourceReader: sinon.stub().callsFake(() => createReader()), - getReader: sinon.stub().callsFake(() => createReader()), + const projectResources = { getStage: sinon.stub().returns({ getId: () => currentStage.id || "initial", getWriter: sinon.stub().returns({ @@ -38,6 +34,19 @@ function createMockProject(name = "test.project", id = "test-project-id") { useResultStage: sinon.stub().callsFake(() => { currentStage = {id: "result"}; }), + getResourceTagOperations: sinon.stub().returns({ + projectTagOperations: new Map(), + buildTagOperations: new Map(), + }), + buildFinished: sinon.stub(), + }; + + return { + getName: () => name, + getId: () => id, + getSourceReader: sinon.stub().callsFake(() => createReader()), + getReader: sinon.stub().callsFake(() => createReader()), + getProjectResources: () => projectResources, _getCurrentStage: () => currentStage, _getResultStageReader: () => resultStageReader }; @@ -192,9 +201,9 @@ test("setTasks initializes project stages", async (t) => { await cache.setTasks(["task1", "task2", "task3"]); - t.true(project.initStages.calledOnce, "initStages called once"); + t.true(project.getProjectResources().initStages.calledOnce, "initStages called once"); t.deepEqual( - project.initStages.firstCall.args[0], + project.getProjectResources().initStages.firstCall.args[0], ["task/task1", "task/task2", "task/task3"], "Stage names generated correctly" ); @@ -207,7 +216,7 @@ test("setTasks with empty task list", async (t) => { await cache.setTasks([]); - t.true(project.initStages.calledWith([]), "initStages called with empty array"); + t.true(project.getProjectResources().initStages.calledWith([]), "initStages called with empty array"); }); test("allTasksCompleted switches to result stage", async (t) => { @@ -217,7 +226,7 @@ test("allTasksCompleted switches to result stage", async (t) => { const changedPaths = await cache.allTasksCompleted(); - t.true(project.useResultStage.calledOnce, "useResultStage called"); + t.true(project.getProjectResources().useResultStage.calledOnce, "useResultStage called"); t.true(Array.isArray(changedPaths), "Returns array of changed paths"); t.true(cache.isFresh(), "Cache is fresh after all tasks completed"); }); @@ -277,7 +286,7 @@ test("prepareTaskExecutionAndValidateCache: task needs execution when no cache e const canUseCache = await cache.prepareTaskExecutionAndValidateCache("myTask"); t.false(canUseCache, "Task cannot use cache"); - t.true(project.useStage.calledWith("task/myTask"), "Project switched to task stage"); + t.true(project.getProjectResources().useStage.calledWith("task/myTask"), "Project switched to task stage"); }); test("prepareTaskExecutionAndValidateCache: switches project to correct stage", async (t) => { @@ -288,7 +297,7 @@ test("prepareTaskExecutionAndValidateCache: switches project to correct stage", await cache.setTasks(["task1", "task2"]); await cache.prepareTaskExecutionAndValidateCache("task2"); - t.true(project.useStage.calledWith("task/task2"), "Switched to task2 stage"); + t.true(project.getProjectResources().useStage.calledWith("task/task2"), "Switched to task2 stage"); }); test("recordTaskResult: creates task cache", async (t) => { @@ -595,5 +604,5 @@ test("Empty task list doesn't fail", async (t) => { await cache.setTasks([]); - t.true(project.initStages.calledWith([]), "initStages called with empty array"); + t.true(project.getProjectResources().initStages.calledWith([]), "initStages called with empty array"); }); diff --git a/packages/project/test/lib/build/helpers/ProjectBuildContext.js b/packages/project/test/lib/build/helpers/ProjectBuildContext.js index 02f967371ed..16310e07119 100644 --- a/packages/project/test/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/test/lib/build/helpers/ProjectBuildContext.js @@ -126,33 +126,7 @@ test("executeCleanupTasks", (t) => { }); test.serial("getResourceTagCollection", async (t) => { - const projectAcceptsTagStub = sinon.stub().returns(false); - projectAcceptsTagStub.withArgs("project-tag").returns(true); - const projectContextAcceptsTagStub = sinon.stub().returns(false); - projectContextAcceptsTagStub.withArgs("project-context-tag").returns(true); - - class DummyResourceTagCollection { - constructor({allowedTags, allowedNamespaces}) { - t.deepEqual(allowedTags, [ - "ui5:OmitFromBuildResult", - "ui5:IsBundle" - ], - "Correct allowedTags parameter supplied"); - - t.deepEqual(allowedNamespaces, [ - "build" - ], - "Correct allowedNamespaces parameter supplied"); - } - acceptsTag(tag) { - // Redirect to stub - return projectContextAcceptsTagStub(tag); - } - } - - const ProjectBuildContext = await esmock("../../../../lib/build/helpers/ProjectBuildContext.js", { - "@ui5/fs/internal/ResourceTagCollection": DummyResourceTagCollection - }); + const ProjectBuildContext = (await import("../../../../lib/build/helpers/ProjectBuildContext.js")).default; const buildContext = {}; const project = { getName: () => "project", @@ -163,30 +137,24 @@ test.serial("getResourceTagCollection", async (t) => { project ); - const fakeProjectCollection = { - acceptsTag: projectAcceptsTagStub + const fakeCollection = { + acceptsTag: sinon.stub().returns(true) }; + const getResourceTagCollectionStub = sinon.stub().returns(fakeCollection); const fakeResource = { getProject: () => { return { - getResourceTagCollection: () => fakeProjectCollection + getResourceTagCollection: getResourceTagCollectionStub }; }, getPath: () => "/resource/path", hasProject: () => true }; - const collection1 = projectBuildContext.getResourceTagCollection(fakeResource, "project-tag"); - t.is(collection1, fakeProjectCollection, "Returned tag collection of resource project"); - - const collection2 = projectBuildContext.getResourceTagCollection(fakeResource, "project-context-tag"); - t.true(collection2 instanceof DummyResourceTagCollection, - "Returned tag collection of project build context"); - - t.throws(() => { - projectBuildContext.getResourceTagCollection(fakeResource, "not-accepted-tag"); - }, { - message: `Could not find collection for resource /resource/path and tag not-accepted-tag` - }); + const collection = projectBuildContext.getResourceTagCollection(fakeResource, "some-tag"); + t.is(collection, fakeCollection, "Returned tag collection from resource's project"); + t.is(getResourceTagCollectionStub.callCount, 1, "getResourceTagCollection called once"); + t.is(getResourceTagCollectionStub.firstCall.args[0], fakeResource, "Called with resource"); + t.is(getResourceTagCollectionStub.firstCall.args[1], "some-tag", "Called with tag"); }); test("getResourceTagCollection: Assigns project to resource if necessary", (t) => { @@ -404,9 +372,7 @@ test("possiblyRequiresBuild: has build-manifest", (t) => { getType: sinon.stub().returns("bar"), getBuildManifest: () => { return { - buildManifest: { - manifestVersion: "0.1" - }, + manifestVersion: "0.1", timestamp: "2022-07-28T12:00:00.000Z" }; } @@ -425,9 +391,7 @@ test.serial("getBuildMetadata", (t) => { getType: sinon.stub().returns("bar"), getBuildManifest: () => { return { - buildManifest: { - manifestVersion: "0.1" - }, + manifestVersion: "0.1", timestamp: "2022-07-28T12:00:00.000Z" }; } diff --git a/packages/project/test/lib/build/helpers/createBuildManifest.integration.js b/packages/project/test/lib/build/helpers/createBuildManifest.integration.js index 2ff65d198ef..c6b49792b52 100644 --- a/packages/project/test/lib/build/helpers/createBuildManifest.integration.js +++ b/packages/project/test/lib/build/helpers/createBuildManifest.integration.js @@ -45,7 +45,7 @@ const buildConfig = { test("Create project from application project providing a build manifest", async (t) => { const inputProject = await Specification.create(applicationAConfig); - inputProject.getResourceTagCollection().setTag("/resources/id1/foo.js", "ui5:HasDebugVariant"); + inputProject.getProjectResourceTagCollection().setTag("/resources/id1/foo.js", "ui5:HasDebugVariant"); const taskRepository = { getVersions: async () => ({a: "a", b: "b"}) @@ -63,7 +63,7 @@ test("Create project from application project providing a build manifest", async t.truthy(project, "Module was able to create project from build manifest metadata"); t.is(project.getName(), project.getName(), "Archive project has correct name"); t.is(project.getNamespace(), project.getNamespace(), "Archive project has correct namespace"); - t.is(project.getResourceTagCollection().getTag("/resources/id1/foo.js", "ui5:HasDebugVariant"), true, + t.is(project.getProjectResourceTagCollection().getTag("/resources/id1/foo.js", "ui5:HasDebugVariant"), true, "Archive project has correct tag"); t.is(project.getVersion(), "2.0.0", "Archive project has version from archive module"); @@ -77,7 +77,7 @@ test("Create project from application project providing a build manifest", async test("Create project from library project providing a build manifest", async (t) => { const inputProject = await Specification.create(libraryEConfig); - inputProject.getResourceTagCollection().setTag("/resources/library/e/file.js", "ui5:HasDebugVariant"); + inputProject.getProjectResourceTagCollection().setTag("/resources/library/e/file.js", "ui5:HasDebugVariant"); const taskRepository = { getVersions: async () => ({a: "a", b: "b"}) @@ -95,7 +95,7 @@ test("Create project from library project providing a build manifest", async (t) t.truthy(project, "Module was able to create project from build manifest metadata"); t.is(project.getName(), project.getName(), "Archive project has correct name"); t.is(project.getNamespace(), project.getNamespace(), "Archive project has correct namespace"); - t.is(project.getResourceTagCollection().getTag("/resources/library/e/file.js", "ui5:HasDebugVariant"), true, + t.is(project.getProjectResourceTagCollection().getTag("/resources/library/e/file.js", "ui5:HasDebugVariant"), true, "Archive project has correct tag"); t.is(project.getVersion(), "2.0.0", "Archive project has version from archive module"); diff --git a/packages/project/test/lib/build/helpers/createBuildManifest.js b/packages/project/test/lib/build/helpers/createBuildManifest.js index 5a9e17df114..e62b41a5e36 100644 --- a/packages/project/test/lib/build/helpers/createBuildManifest.js +++ b/packages/project/test/lib/build/helpers/createBuildManifest.js @@ -77,7 +77,7 @@ test("Missing parameter: signature", async (t) => { test("Create application from project with build manifest", async (t) => { const project = await Specification.create(applicationProjectInput); - project.getResourceTagCollection().setTag("/resources/id1/foo.js", "ui5:HasDebugVariant"); + project.getProjectResourceTagCollection().setTag("/resources/id1/foo.js", "ui5:HasDebugVariant"); const taskRepository = { getVersions: async () => ({builderVersion: "", fsVersion: ""}) @@ -133,7 +133,7 @@ test("Create application from project with build manifest", async (t) => { test("Create library from project with build manifest", async (t) => { const project = await Specification.create(libraryProjectInput); - project.getResourceTagCollection().setTag("/resources/library/d/foo.js", "ui5:HasDebugVariant"); + project.getProjectResourceTagCollection().setTag("/resources/library/d/foo.js", "ui5:HasDebugVariant"); const taskRepository = { getVersions: async () => ({builderVersion: "", fsVersion: ""}) From a359945640f6b45f0998c6f9636fb8f6a237f701 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 6 Mar 2026 16:25:32 +0100 Subject: [PATCH 165/223] refactor(fs): Fix resource integrity for resources without content --- packages/fs/lib/Resource.js | 2 +- packages/fs/test/lib/package-exports.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index ac873613478..df5009bfe6b 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -792,7 +792,7 @@ class Resource { isDirectory: this.#isDirectory, byteSize: this.#isDirectory ? undefined : await this.getSize(), lastModified: this.#lastModified, - integrity: this.#isDirectory ? undefined : await this.getIntegrity(), + integrity: this.#isDirectory ? undefined : (this.#contentType ? await this.getIntegrity() : undefined), sourceMetadata: clone(this.#sourceMetadata) }; diff --git a/packages/fs/test/lib/package-exports.js b/packages/fs/test/lib/package-exports.js index 13201c3d901..9000ba782cb 100644 --- a/packages/fs/test/lib/package-exports.js +++ b/packages/fs/test/lib/package-exports.js @@ -12,7 +12,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/fs/package.json"); - t.is(Object.keys(packageJson.exports).length, 12); + t.is(Object.keys(packageJson.exports).length, 13); }); // Public API contract (exported modules) @@ -74,6 +74,10 @@ test("check number of exports", (t) => { exportedSpecifier: "@ui5/fs/internal/ResourceTagCollection", mappedModule: "../../lib/ResourceTagCollection.js" }, + { + exportedSpecifier: "@ui5/fs/internal/MonitoredResourceTagCollection", + mappedModule: "../../lib/MonitoredResourceTagCollection.js" + }, ].forEach(({exportedSpecifier, mappedModule}) => { test(`${exportedSpecifier}`, async (t) => { const actual = await import(exportedSpecifier); From ae00c61ae7b06f6bb30c2fe66ab820c74313480e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 6 Mar 2026 16:31:51 +0100 Subject: [PATCH 166/223] test(logger): Fix tests --- packages/logger/test/lib/loggers/ProjectBuild.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/logger/test/lib/loggers/ProjectBuild.js b/packages/logger/test/lib/loggers/ProjectBuild.js index cc8ae00d45a..446732d41fe 100644 --- a/packages/logger/test/lib/loggers/ProjectBuild.js +++ b/packages/logger/test/lib/loggers/ProjectBuild.js @@ -115,6 +115,7 @@ test.serial("Start task", (t) => { projectType: "projectType", status: "task-start", taskName: "task.a", + isDifferentialBuild: undefined, }, "Metadata event has expected payload"); t.is(logHandler.callCount, 0, "No log event emitted"); @@ -135,6 +136,7 @@ test.serial("End task", (t) => { projectType: "projectType", status: "task-end", taskName: "task.a", + isDifferentialBuild: undefined, }, "Metadata event has expected payload"); t.is(logHandler.callCount, 0, "No log event emitted"); From f6eee0044c134d46c53e88142ad9071ff8eaf15a Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Fri, 6 Mar 2026 17:54:05 +0100 Subject: [PATCH 167/223] test(project): Add case for self-contained builds (Standard Tasks) --- .../lib/build/ProjectBuilder.integration.js | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 8d27c8679fd..51bdff82611 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -596,6 +596,108 @@ test.serial("Build application.a (Custom Component preload configuration)", asyn }); }); +test.serial("Build application.a (self-contained build)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // We're executing a self-contained build including dependencies (as with "ui5 build self-contained --all") + // and testing if the output contains the expected self-contained bundle. + // Then, we're changing the content only of application.a + // and testing if the self-contained build output changes accordingly. + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, selfContained: "self-contained", + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // Check that output contains the correct content: + const builtFileContent = await fs.readFile(`${destPath}/resources/sap-ui-custom.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`"id1/test.js":'function test(t){var o=t;console.log(o)}test();\\n'`)); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, selfContained: "self-contained", + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + + // Remove the file "test.js" from application.a: + await fs.rm(`${fixtureTester.fixturePath}/webapp/test.js`); + + // #3 build (with cache, with changes) + // Dependencies should get skipped, application.a should get rebuilt: + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, selfContained: "self-contained", + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + "transformBootstrapHtml", + ] + } + } + } + }); + + // Check that output contains the correct content (test.js should be missing): + const builtFileContent2 = await fs.readFile(`${destPath}/resources/sap-ui-custom.js`, {encoding: "utf8"}); + t.false(builtFileContent2.includes(`"id1/test.js":`)); + + + // #4 build (with cache, no changes) + // Run a self-contained build but with a different config which defines a custom preload. + // The build should run and the output should still contain the expected self-contained bundle + // (tasks "generateComponentPreload" and "generateLibraryPreload" should not get executed): + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-preload-config.yaml"}, + config: {destPath, cleanDest: true, selfContained: "self-contained", + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Check that output contains still the correct content: + const builtFileContent3 = await fs.readFile(`${destPath}/resources/sap-ui-custom.js`, {encoding: "utf8"}); + t.false(builtFileContent3.includes(`"id1/test.js":`)); + + + // #5 build (with cache, no changes) + // Run a self-contained build but without dependencies: + // (everything should get skipped) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5.yaml"}, + config: {destPath, cleanDest: true, selfContained: "self-contained"}, + assertions: { + projects: {} + } + }); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; From 0bb8c40ef2aa4c89d0fc8e5a71a54703ecbd8ba3 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 9 Mar 2026 14:13:56 +0100 Subject: [PATCH 168/223] refactor(project): Fix incorrect stage signature when using delta stage cache --- .../lib/build/cache/ProjectBuildCache.js | 17 ++++++++--------- .../test/lib/build/BuildServer.integration.js | 4 +++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index c242240e19b..9709ca995ca 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -443,7 +443,8 @@ export default class ProjectBuildCache { } return true; // No need to execute the task } else { - log.verbose(`No cached stage found for task ${taskName} in project ${this.#project.getName()}`); + log.verbose(`No cached stage found for task ${taskName} in project ${this.#project.getName()}. ` + + `Attempting to find delta cached stage...`); // TODO: Optimize this crazy thing const projectDeltas = taskCache.getProjectIndexDeltas(); const depDeltas = taskCache.getDependencyIndexDeltas(); @@ -458,7 +459,7 @@ export default class ProjectBuildCache { // Combine deltas of dependency stages with cached project signatures const depDeltaSignatures = combineTwoArraysFast( projectSignatures, - Array.from(projectDeltas.keys()), + Array.from(depDeltas.keys()), ).map((signaturePair) => { return createStageSignature(...signaturePair); }); @@ -477,8 +478,6 @@ export default class ProjectBuildCache { // Check whether the stage actually changed if (oldStageSig !== deltaStageCache.signature) { - this.#currentStageSignatures.set(stageName, [foundProjectSig, foundDepSig]); - // Cached stage likely differs from the previous one (if any) // Add all resources written by the cached stage to the set of written/potentially changed resources for (const resourcePath of deltaStageCache.writtenResourcePaths) { @@ -492,9 +491,10 @@ export default class ProjectBuildCache { const projectDeltaInfo = projectDeltas.get(foundProjectSig); const dependencyDeltaInfo = depDeltas.get(foundDepSig); - const newSignature = createStageSignature( - projectDeltaInfo?.newSignature ?? foundProjectSig, - dependencyDeltaInfo?.newSignature ?? foundDepSig); + const newProjSig = projectDeltaInfo?.newSignature ?? foundProjectSig; + const newDepSig = dependencyDeltaInfo?.newSignature ?? foundDepSig; + const newSignature = createStageSignature(newProjSig, newDepSig); + this.#currentStageSignatures.set(stageName, [newProjSig, newDepSig]); log.verbose( `Using delta cached stage for task ${taskName} in project ${this.#project.getName()} ` + @@ -1036,8 +1036,7 @@ export default class ProjectBuildCache { for (const [taskName, taskCache] of this.#taskCache) { if (taskCache.hasNewOrModifiedCacheEntries()) { const [projectRequests, dependencyRequests] = taskCache.toCacheObjects(); - log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()} ` + - `with build signature ${this.#buildSignature}`); + log.verbose(`Storing task cache metadata for task ${taskName} in project ${this.#project.getName()}`); const writes = []; if (projectRequests) { writes.push(this.#cacheManager.writeTaskMetadata( diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index f8fcb3023af..f4e41780eb5 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -196,7 +196,7 @@ test.serial("Serve application.a, request library resource", async (t) => { ); }); -test.serial.skip("Serve library", async (t) => { +test.serial("Serve library", async (t) => { const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "library.d"); // #1 request with empty cache @@ -263,6 +263,8 @@ test.serial.skip("Serve library", async (t) => { await fs.writeFile(changedFilePath, originalContent); + await setTimeout(500); // Wait for the file watcher to detect and propagate the change + // #4 request with cache (no changes) const resourceContent2 = await fixtureTester.requestResource({ resource: "/resources/library/d/some.js", From 76ff6d68494aa7b492df0ee4bc0d9681a8ba31c4 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Mon, 9 Mar 2026 18:40:50 +0100 Subject: [PATCH 169/223] test(project): Add cases for custom bundling builds --- .../application.a/ui5-custom-bundling.yaml | 19 ++ .../lib/build/ProjectBuilder.integration.js | 249 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/ui5-custom-bundling.yaml diff --git a/packages/project/test/fixtures/application.a/ui5-custom-bundling.yaml b/packages/project/test/fixtures/application.a/ui5-custom-bundling.yaml new file mode 100644 index 00000000000..f3c8a243a2b --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-custom-bundling.yaml @@ -0,0 +1,19 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + bundles: + - bundleDefinition: + name: "custom-bundle.js" + defaultFileTypes: + - ".js" + - ".json" + sections: + - mode: preload + name: "customBundle" + filters: + - "id1/Component.js" + - "id1/newFile.js" + resolve: false diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 51bdff82611..19b0275bb11 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -698,6 +698,255 @@ test.serial("Build application.a (self-contained build)", async (t) => { }); }); +test.serial("Build application.a (Custom bundling)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // In this test, we're testing the behavior of a custom bundling configuration + // which is defined in "ui5-custom-bundling.yaml". + // This config generates a custom bundle in various modes. + // The bundle includes resources by a filter ("Component.js" & "newFile.js") which are added at #3 and #4 build. + + // #1 build with custom bundle configuration (with empty cache) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: false}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // Verify that the custom bundle was created and contains the expected content: + const customBundlePath = `${destPath}/resources/custom-bundle.js`; + const customBundleContent = await fs.readFile(customBundlePath, {encoding: "utf8"}); + t.false(customBundleContent.includes("sap.ui.predefine("), + "preload Mode: Custom bundle should not contain sap.ui.predefine() at this stage" + ); + t.false(customBundleContent.includes("sap.ui.require.preload("), + "preload Mode: Custom bundle should not contain sap.ui.require.preload() at this stage" + ); + // Verify that source map was created: + const sourceMapPath = `${destPath}/resources/custom-bundle.js.map`; + const sourceMapContent = JSON.parse(await fs.readFile(sourceMapPath, {encoding: "utf8"})); + t.true(sourceMapContent.sections.length === 0, "Source map file should not have content at this stage"); + + + // #2 build with custom bundle (with cache, no changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + + // Add a source file which is matched by the filter (UI5 Module): + const newComponentFilepath = `${fixtureTester.fixturePath}/webapp/Component.js`; + await fs.appendFile(newComponentFilepath, + `sap.ui.define(["sap/ui/core/UIComponent", "sap/ui/core/ComponentSupport"], (UIComponent) => { + "use strict"; + return UIComponent.extend("id1.Component", { + metadata: { + manifest: "json", + interfaces: ["sap.ui.core.IAsyncContentCreation"], + } + }); +});`); + + // #3 build with custom bundle (with cache, with changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + // Verify that the updated custom bundle contains the change: + // (the bundle should now contain sap.ui.predefine() due to the added UI5 module "Component.js") + const customBundleContent2 = await fs.readFile(customBundlePath, {encoding: "utf8"}); + t.true(customBundleContent2.includes("sap.ui.predefine("), + "preload Mode: Custom bundle should contain sap.ui.predefine() now" + ); + t.false(customBundleContent2.includes("sap.ui.require.preload("), + "preload Mode: Custom bundle should not contain sap.ui.require.preload() at this stage" + ); + // Verify that source map was created and contains the change: + const sourceMapContent2 = JSON.parse(await fs.readFile(sourceMapPath, {encoding: "utf8"})); + t.true(sourceMapContent2.sections.length > 0, "Source map file should have content now"); + + + // Add another source file which is matched by the filter (non-UI5 module): + const newTestFilepath = `${fixtureTester.fixturePath}/webapp/newFile.js`; + await fs.appendFile(newTestFilepath, `console.log("another source file");`); + + // #4 build with custom bundle (with cache, with changes) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + // Verify that the custom bundle was created and contains the expected content: + // (the bundle should now contain sap.ui.require.preload() due to the added non-UI5 module "newFile.js") + const customBundleContent3 = await fs.readFile(customBundlePath, {encoding: "utf8"}); + t.true(customBundleContent3.includes("sap.ui.predefine("), + "preload Mode: Custom bundle should contain sap.ui.predefine() still" + ); + t.true(customBundleContent3.includes("sap.ui.require.preload("), + "preload Mode: Custom bundle should contain sap.ui.require.preload() now" + ); + // Verify that source map was created and contains the change: + const sourceMapContent3 = JSON.parse(await fs.readFile(sourceMapPath, {encoding: "utf8"})); + t.true(sourceMapContent3.sections.length > 0, "Source map file should have content still"); + + + // ---------------------------------------------------------------------------------- + // ---------------------------- Test other bundle modes: ---------------------------- + // ---------------------------------------------------------------------------------- + // Switch to "raw" mode: + const ui5YamlContent = await fs.readFile(`${fixtureTester.fixturePath}/ui5-custom-bundling.yaml`); + await fs.writeFile(`${fixtureTester.fixturePath}/ui5-custom-bundling.yaml`, + ui5YamlContent.toString().replace(`- mode: preload`, `- mode: raw`)); + + // #5 build with custom bundle configuration (with empty cache) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: false}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Verify that the custom bundle was created and contains the expected content: + // (the bundle should contain sap.ui.define() now) + const customBundleContent4 = await fs.readFile(customBundlePath, {encoding: "utf8"}); + t.false(customBundleContent4.includes("sap.ui.require.preload("), + "raw Mode: Custom bundle should not contain sap.ui.require.preload() anymore" + ); + t.false(customBundleContent4.includes("sap.ui.predefine("), + "raw Mode: Custom bundle should not contain sap.ui.predefine() anymore" + ); + t.true(customBundleContent4.includes("sap.ui.define("), + "raw Mode: Custom bundle should contain sap.ui.define() now" + ); + t.true(customBundleContent4.includes(`console.log("another source file");`)); + t.true(customBundleContent4.includes(`id1.Component`)); + // Verify that source map was created and contains the change: + const sourceMapContent4 = JSON.parse(await fs.readFile(sourceMapPath, {encoding: "utf8"})); + t.true(sourceMapContent4.sections.length > 0, "Source map file should have content still"); + + + // Switch to "require" mode: + const ui5YamlContent2 = await fs.readFile(`${fixtureTester.fixturePath}/ui5-custom-bundling.yaml`); + await fs.writeFile(`${fixtureTester.fixturePath}/ui5-custom-bundling.yaml`, + ui5YamlContent2.toString().replace(`- mode: raw`, `- mode: require`)); + + // #6 build with custom bundle configuration (with empty cache) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: false}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Verify that the custom bundle was created and contains the expected content: + // (the bundle should contain sap.ui.require() now) + const customBundleContent5 = await fs.readFile(customBundlePath, {encoding: "utf8"}); + t.false(customBundleContent5.includes("sap.ui.require.preload("), + "require Mode: Custom bundle should not contain sap.ui.require.preload() anymore" + ); + t.false(customBundleContent5.includes("sap.ui.predefine("), + "require Mode: Custom bundle should not contain sap.ui.predefine() anymore" + ); + t.false(customBundleContent5.includes("sap.ui.define("), + "require Mode: Custom bundle should not contain sap.ui.define() anymore" + ); + t.true(customBundleContent5.includes("sap.ui.require("), + "require Mode: Custom bundle should contain sap.ui.require() now" + ); + t.true(customBundleContent5.includes(`id1/newFile`)); + t.false(customBundleContent5.includes(`console.log("another source file");`)); + t.true(customBundleContent5.includes(`id1/Component`)); + // Verify that source map was created and contains the change: + const sourceMapContent5 = JSON.parse(await fs.readFile(sourceMapPath, {encoding: "utf8"})); + t.true(sourceMapContent5.sections.length > 0, "Source map file should have content still"); + + + // Switch to "bundleInfo" mode: + const ui5YamlContent3 = await fs.readFile(`${fixtureTester.fixturePath}/ui5-custom-bundling.yaml`); + await fs.writeFile(`${fixtureTester.fixturePath}/ui5-custom-bundling.yaml`, + ui5YamlContent3.toString().replace(`- mode: require`, `- mode: bundleInfo`)); + + // #7 build with custom bundle configuration (with empty cache) + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, + config: {destPath, cleanDest: false}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Verify that the custom bundle was created and contains the expected content: + // (the bundle should contain sap.ui.loader.config() now) + const customBundleContent6 = await fs.readFile(customBundlePath, {encoding: "utf8"}); + t.false(customBundleContent6.includes("sap.ui.require.preload("), + "bundleInfo Mode: Custom bundle should not contain sap.ui.require.preload() anymore" + ); + t.false(customBundleContent6.includes("sap.ui.predefine("), + "bundleInfo Mode: Custom bundle should not contain sap.ui.predefine() anymore" + ); + t.false(customBundleContent6.includes("sap.ui.define("), + "bundleInfo Mode: Custom bundle should not contain sap.ui.define() anymore" + ); + t.false(customBundleContent6.includes("sap.ui.require("), + "bundleInfo Mode: Custom bundle should not contain sap.ui.require() anymore" + ); + t.true(customBundleContent6.includes("sap.ui.loader.config({bundlesUI5:{"), + "bundleInfo Mode: Custom bundle should contain sap.ui.loader.config() now" + ); + t.true(customBundleContent6.includes(`id1/newFile`)); + t.false(customBundleContent6.includes(`console.log("another source file");`)); + t.true(customBundleContent6.includes(`id1/Component`)); + // Verify that source map was created and contains the change: + const sourceMapContent6 = JSON.parse(await fs.readFile(sourceMapPath, {encoding: "utf8"})); + t.true(sourceMapContent6.sections.length > 0, "Source map file should have content still"); +}); + test.serial("Build library.d project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; From 74a20c337e30e9dfcb593351879bdc57e03b1430 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 10 Mar 2026 07:30:12 +0200 Subject: [PATCH 170/223] test(project): Add cases for file deletion (TC4) --- .../lib/build/ProjectBuilder.integration.js | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 19b0275bb11..62c7b9248a4 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -186,6 +186,39 @@ test.serial("Build application.a project multiple times", async (t) => { } } }); + + + // Add a new file to application.a + await fs.writeFile(`${fixtureTester.fixturePath}/webapp/someNew.js`, + `console.log("SOME NEW CONTENT");\n` + ); + + // #10 build (with cache, with changes - someNew.js added) + // Tasks that don't depend on someNew.js can reuse their caches from build #9. + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + }} + } + }); + + await fs.rm(`${fixtureTester.fixturePath}/webapp/someNew.js`); + + // #11 build (with cache, with changes - someNew.js removed) + // Source state matches build #9's cached result -> cache reused, everything skipped + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {}, + } + }); }); test.serial("Build application.a (custom task and tag handling)", async (t) => { @@ -1028,6 +1061,10 @@ test.serial("Build library.d project multiple times", async (t) => { ) ); + await fs.writeFile(`${fixtureTester.fixturePath}/main/src/library/d/someNew.js`, + `console.log("SOME NEW CONTENT");\n` + ); + // #5 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, @@ -1035,6 +1072,49 @@ test.serial("Build library.d project multiple times", async (t) => { projects: {"library.d": {}} } }); + + await fs.rm(`${fixtureTester.fixturePath}/main/src/library/d/someNew.js`); + + // #6 build (with cache, with changes - someNew.js removed) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"library.d": { + skippedTasks: [ + "buildThemes", + "enhanceManifest", + "escapeNonAsciiCharacters", + "replaceBuildtime", + ] + }}, + } + }); + + // Re-add someNew.js (restores source state to match build #5) + await fs.writeFile(`${fixtureTester.fixturePath}/main/src/library/d/someNew.js`, + `console.log("SOME NEW CONTENT");\n` + ); + + // #7 build (with cache, with changes - someNew.js re-added) + // Source state now matches build #5's cached result -> cache reused + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {}, + } + }); + + // Remove someNew.js again + await fs.rm(`${fixtureTester.fixturePath}/main/src/library/d/someNew.js`); + + // #8 build (with cache, with changes - someNew.js removed again) + // Source state matches build #6's cached result -> cache reused, everything skipped + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {}, + } + }); }); test.serial("Build library.d (Custom Library preload configuration)", async (t) => { @@ -1305,6 +1385,39 @@ test.serial("Build component.a project multiple times", async (t) => { projects: {} } }); + + + // Add a new file to component.a + await fs.writeFile(`${fixtureTester.fixturePath}/src/someNew.js`, + `console.log("SOME NEW CONTENT");\n` + ); + + // #7 build (with cache, with changes - someNew.js added) + // Tasks that don't depend on someNew.js can reuse their caches from build #3. + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {"component.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + }} + } + }); + + await fs.rm(`${fixtureTester.fixturePath}/src/someNew.js`); + + // #8 build (with cache, with changes - someNew.js removed) + // Source state matches build #6's cached result -> cache reused, everything skipped + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {}, + } + }); }); test.serial("Build component.a (Custom Component preload configuration)", async (t) => { From 5bf929e17ffcbcac1eb10f27c40f1f48f0697bf5 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 10 Mar 2026 11:37:15 +0100 Subject: [PATCH 171/223] test(project): Refactor "custom tasks 2" case --- .../lib/build/ProjectBuilder.integration.js | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 62c7b9248a4..502737bace6 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -351,13 +351,11 @@ test.serial("Build application.a (multiple custom tasks)", async (t) => { }); }); -test.serial.skip("Build application.a (multiple custom tasks 2)", async (t) => { +test.serial("Build application.a (multiple custom tasks 2)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; // This test should cover a scenario with multiple custom tasks. - // Specifically, it's invalidating the task cache by only modifying tags on resources, - // but not the resources themselves. // #1 build (no cache, no changes, with custom tasks) // During this build, "custom-task-0" sets the tag "isDebugVariant" to test.js. @@ -373,24 +371,69 @@ test.serial.skip("Build application.a (multiple custom tasks 2)", async (t) => { }); - // #2 build (with cache, no changes, with custom tasks) + // Modify file to trigger a new build + // (this is related to the custom tasks): + await fs.appendFile(`${fixtureTester.fixturePath}/webapp/test.js`, `console.log("NEW FILE");`); + + // #2 build (with cache, with changes, with custom tasks) // During this build, "custom-task-0" sets a different tag to test.js (namely "OmitFromBuildResult"). // "custom-task-1" again checks if it's able to read this different tag. - // It's expected that both custom tasks are not getting skipped during this build, - // even though any resources weren't modified. - // FIXME: Currently, the entire build is skipped and therefore the custom tasks are not executed. await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-multiple-customTasks-2.yaml"}, config: {destPath, cleanDest: true}, assertions: { projects: { - "application.a": {} // TODO: add non-relevant skippedTasks here, once the tag handling works + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } } } }); // Check that test.js is omitted from build output: await t.throwsAsync(fs.readFile(`${destPath}/test.js`, {encoding: "utf8"})); + + + // Add new file to trigger another build + // (this is unrelated to the custom tasks): + await fs.writeFile(`${fixtureTester.fixturePath}/webapp/newFile.js`, `console.log("NEW FILE");`); + + // #4 build (with cache, with changes, with custom tasks) + // During this build, both custom tasks are expected to get skipped. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks-2.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "custom-task-0", + "custom-task-1", + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // #5 build (with cache, no changes, with custom tasks) + // During this build, everything should get skipped. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-multiple-customTasks-2.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); }); test.serial.skip("Build application.a (dependency content changes)", async (t) => { From 42d29cac6b731643abfbd390c2a9073752bd7379 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Tue, 10 Mar 2026 13:49:01 +0100 Subject: [PATCH 172/223] test(project): Add tests for various dependency relations The tests cover the following dependency types: - component - library - theme-library - module --- .../lib/build/ProjectBuilder.integration.js | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 502737bace6..ea58b6cebf8 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -221,6 +221,122 @@ test.serial("Build application.a project multiple times", async (t) => { }); }); +test.serial("Build application.a (with various dependencies)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 build (with empty cache) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + + // #2 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + + // Add a "component" dependency to application.a: + await fixtureTester.addComponentDependency(`${fixtureTester.fixturePath}/webapp`); + + // #3 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "component.z": {}, + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // Add a "library" dependency to application.a: + await fixtureTester.addLibraryDependency(`${fixtureTester.fixturePath}/webapp`); + + // #4 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.z": {}, + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // Add a "themelib" dependency to application.a: + await fixtureTester.addThemeLibraryDependency(`${fixtureTester.fixturePath}/webapp`); + + // #5 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.z": {}, + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // Add a "module" dependency to application.a: + await fixtureTester.addModuleDependency(`${fixtureTester.fixturePath}/webapp`); + + // #6 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "module.z": {}, + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); +}); + test.serial("Build application.a (custom task and tag handling)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; @@ -1160,6 +1276,72 @@ test.serial("Build library.d project multiple times", async (t) => { }); }); +test.serial("Build library.d (with various dependencies)", async (t) => { + const fixtureTester = new FixtureTester(t, "library.d"); + const destPath = fixtureTester.destPath; + + // #1 build (with empty cache) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false}, + assertions: { + projects: {"library.d": {}} + } + }); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true}, + assertions: { + projects: {} + } + }); + + + // Add a "library" dependency to library.d: + await fixtureTester.addLibraryDependency(`${fixtureTester.fixturePath}/main/src/library/d`); + + // #3 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.z": {}, + "library.d": { + skippedTasks: [ + "buildThemes", + "enhanceManifest", + "escapeNonAsciiCharacters", + "replaceBuildtime", + ] + }, + } + } + }); + + + // Add a "themelib" dependency to library.d: + await fixtureTester.addThemeLibraryDependency(`${fixtureTester.fixturePath}/main/src/library/d`); + + // #4 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.z": {}, + "library.d": { + skippedTasks: [ + "buildThemes", + "enhanceManifest", + "escapeNonAsciiCharacters", + "replaceBuildtime", + ] + } + } + } + }); +}); + test.serial("Build library.d (Custom Library preload configuration)", async (t) => { const fixtureTester = new FixtureTester(t, "library.d"); const destPath = fixtureTester.destPath; @@ -1347,6 +1529,49 @@ test.serial("Build theme.library.e project multiple times", async (t) => { }); }); +test.serial("Build theme.library.e (with various dependencies)", async (t) => { + const fixtureTester = new FixtureTester(t, "theme.library.e"); + const destPath = fixtureTester.destPath; + + // #1 build (with empty cache) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {"theme.library.e": {}} + } + }); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + + // Add a "library" dependency to theme.library.e: + await fixtureTester.addLibraryDependency(`${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme`); + + // #3 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.z": {}, + "theme.library.e": { + skippedTasks: [ + "buildThemes", + "replaceCopyright", + "replaceVersion", + ] + }, + } + } + }); +}); + test.serial("Build component.a project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "component.a"); const destPath = fixtureTester.destPath; @@ -1463,6 +1688,122 @@ test.serial("Build component.a project multiple times", async (t) => { }); }); +test.serial("Build component.a (with various dependencies)", async (t) => { + const fixtureTester = new FixtureTester(t, "component.a"); + const destPath = fixtureTester.destPath; + + // #1 build (with empty cache) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "component.a": {} + } + } + }); + + + // #2 build (with cache, no changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + + // Add a "component" dependency to component.a: + await fixtureTester.addComponentDependency(`${fixtureTester.fixturePath}/src`); + + // #3 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "component.z": {}, + "component.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // Add a "library" dependency to component.a: + await fixtureTester.addLibraryDependency(`${fixtureTester.fixturePath}/src`); + + // #4 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.z": {}, + "component.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // Add a "themelib" dependency to component.a: + await fixtureTester.addThemeLibraryDependency(`${fixtureTester.fixturePath}/src`); + + // #5 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.z": {}, + "component.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); + + + // Add a "module" dependency to component.a: + await fixtureTester.addModuleDependency(`${fixtureTester.fixturePath}/src`); + + // #6 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "module.z": {}, + "component.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + } + } + } + }); +}); + test.serial("Build component.a (Custom Component preload configuration)", async (t) => { const fixtureTester = new FixtureTester(t, "component.a"); const destPath = fixtureTester.destPath; @@ -1649,6 +1990,64 @@ resources: }); }); +test.serial("Build module.b (with various dependencies)", async (t) => { + const fixtureTester = new FixtureTester(t, "module.b"); + const destPath = fixtureTester.destPath; + + // #1 build (no cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "module.b": {} + } + }, + }); + + + // #2 build (with cache, no changes) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + + // Add a "library" dependency to module.b: + await fixtureTester.addLibraryDependency(`${fixtureTester.fixturePath}/dev`); + + // #3 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.z": {}, + "module.b": {} + } + }, + }); + + + // Add a "themelib" dependency to module.b: + await fixtureTester.addThemeLibraryDependency(`${fixtureTester.fixturePath}/dev`); + + // #4 build (no cache, with changes, with dependencies) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.z": {}, + "module.b": {} + } + } + }); +}); + test.serial("Build race condition: file modified during active build", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; @@ -1805,4 +2204,230 @@ class FixtureTester { this._t.deepEqual(actualSkipped, expectedArray); } } + + /** + * Helper function to add a new module dependency ("module.z") to an arbitrary root project. + * + * @param {string} sourceDir - source path of the root project (e.g. `${this.fixturePath}/webapp` for applications) + */ + async addModuleDependency(sourceDir) { + await fs.mkdir(`${this.fixturePath}/node_modules/module.z/dev`, {recursive: true}); + await fs.writeFile(`${this.fixturePath}/node_modules/module.z/dev/devTools.js`, + `console.log("module.z devTools");`); + await fs.writeFile(`${this.fixturePath}/node_modules/module.z/package.json`, + `{ + "name": "module.z", + "version": "1.0.0" +}` + ); + await fs.writeFile(`${this.fixturePath}/node_modules/module.z/ui5.yaml`, + `--- +specVersion: "5.0" +type: module +metadata: + name: module.z +resources: + configuration: + paths: + /resources/z/module/dev/: dev`); + + await fs.writeFile(`${sourceDir}/moduleConsumer.js`, + `sap.ui.define(["z/module/dev/devTools"], () => {});`); + const packageJsonContent = JSON.parse( + await fs.readFile(`${this.fixturePath}/package.json`, {encoding: "utf8"})); + if (!packageJsonContent.dependencies) { + packageJsonContent.dependencies = {}; + } + packageJsonContent.dependencies["module.z"] = "file:../module.z"; + await fs.writeFile(`${this.fixturePath}/package.json`, + JSON.stringify(packageJsonContent) + ); + } + + /** + * Helper function to add a new component dependency ("component.z") to an arbitrary root project. + * + * @param {string} sourceDir - source path of the root project (e.g. `${this.fixturePath}/webapp` for applications) + */ + async addComponentDependency(sourceDir) { + await fs.mkdir(`${this.fixturePath}/node_modules/component.z/src`, {recursive: true}); + await fs.writeFile(`${this.fixturePath}/node_modules/component.z/src/Component.js`, + `sap.ui.define(["sap/ui/core/UIComponent"], function(UIComponent){ + "use strict"; + return UIComponent.extend('component.z.Component', { + createContent: function () { + return new Label({ text: "Hello!" }); + } + }); +}); +`); + await fs.writeFile(`${this.fixturePath}/node_modules/component.z/src/manifest.json`, + `{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "component.z", + "type": "component", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +}`); + await fs.writeFile(`${this.fixturePath}/node_modules/component.z/ui5.yaml`, + `--- +specVersion: "5.0" +type: component +metadata: + name: component.z`); + await fs.writeFile(`${this.fixturePath}/node_modules/component.z/package.json`, + `{ + "name": "component.z", + "version": "1.0.0" +}` + ); + + await fs.writeFile(`${sourceDir}/componentConsumer.js`, + `sap.ui.define(["component/z"], () => {});`); + const packageJsonContent = JSON.parse( + await fs.readFile(`${this.fixturePath}/package.json`, {encoding: "utf8"})); + if (!packageJsonContent.dependencies) { + packageJsonContent.dependencies = {}; + } + packageJsonContent.dependencies["component.z"] = "file:../component.z"; + await fs.writeFile(`${this.fixturePath}/package.json`, + JSON.stringify(packageJsonContent) + ); + } + + /** + * Helper function to add a new library dependency ("library.z") to an arbitrary root project. + * + * @param {string} sourceDir - source path of the root project (e.g. `${this.fixturePath}/webapp` for applications) + */ + async addLibraryDependency(sourceDir) { + await fs.mkdir(`${this.fixturePath}/node_modules/library.z/src/library/z`, {recursive: true}); + await fs.writeFile(`${this.fixturePath}/node_modules/library.z/src/library/z/library.js`, + ` +sap.ui.define([ + "sap/base/util/ObjectPath", + "sap/ui/core/Core", + "sap/ui/core/library" +], function (ObjectPath, Core) { + "use strict"; + + Core.initLibrary({ + name: "library.z", + version: ` + "\"${version}\"" + `, + dependencies: [ + "sap.ui.core" + ], + types: [ + "library.z.ExampleColor" + ], + interfaces: [], + elements: [], + noLibraryCSS: false + }); + const thisLib = ObjectPath.get("library.z"); + + thisLib.ExampleColor = { + Default : "Default", + Highlight : "Highlight" + }; + return thisLib; +});`); + await fs.writeFile(`${this.fixturePath}/node_modules/library.z/src/library/z/.library`, + ` + + + library.z + SAP SE + Some fancy copyright + `+"${version}"+` + + Library Z + +`); + await fs.writeFile(`${this.fixturePath}/node_modules/library.z/ui5.yaml`, + `--- +specVersion: "5.0" +type: library +metadata: + name: library.z +`); + await fs.writeFile(`${this.fixturePath}/node_modules/library.z/package.json`, + `{ + "name": "library.z", + "version": "1.0.0" +}` + ); + + await fs.writeFile(`${sourceDir}/libraryConsumer.js`, + `sap.ui.define(["library/z/library"], + (LibraryZ) => { + console.log(LibraryZ.ExampleColor.Default); +});`); + const packageJsonContent = JSON.parse( + await fs.readFile(`${this.fixturePath}/package.json`, {encoding: "utf8"})); + if (!packageJsonContent.dependencies) { + packageJsonContent.dependencies = {}; + } + packageJsonContent.dependencies["library.z"] = "file:../library.z"; + await fs.writeFile(`${this.fixturePath}/package.json`, + JSON.stringify(packageJsonContent) + ); + } + + /** + * Helper function to add a new theme library dependency ("themelib.z") to an arbitrary root project. + * + * @param {string} sourceDir - source path of the root project (e.g. `${this.fixturePath}/webapp` for applications) + */ + async addThemeLibraryDependency(sourceDir) { + await fs.mkdir(`${this.fixturePath}/node_modules/themelib.z/src/themelib/z/themes/my_theme`, {recursive: true}); + await fs.writeFile( + `${this.fixturePath}/node_modules/themelib.z/src/themelib/z/themes/my_theme/library.source.less`, + `@mycolor: blue; +.sapUiBody { + background-color: @mycolor; +}`); + await fs.writeFile(`${this.fixturePath}/node_modules/themelib.z/src/themelib/z/themes/my_theme/.theme`, + ` + + my_theme + me + ` +"\"${copyright}\"" + ` + ` +"\"${version}\"" + ` +`); + await fs.writeFile(`${this.fixturePath}/node_modules/themelib.z/ui5.yaml`, + `--- +specVersion: "5.0" +type: theme-library +metadata: + name: themelib.z +`); + await fs.writeFile(`${this.fixturePath}/node_modules/themelib.z/package.json`, + `{ + "name": "themelib.z", + "version": "1.0.0" +}` + ); + + await fs.writeFile(`${sourceDir}/themelibConsumer.js`, + `sap.ui.define(["sap/ui/core/Theming"], (Theming) => { + Theming.setTheme("my_theme"); + console.log(Theming.getTheme()); +});`); + const packageJsonContent = JSON.parse( + await fs.readFile(`${this.fixturePath}/package.json`, {encoding: "utf8"})); + if (!packageJsonContent.dependencies) { + packageJsonContent.dependencies = {}; + } + packageJsonContent.dependencies["themelib.z"] = "file:../themelib.z"; + await fs.writeFile(`${this.fixturePath}/package.json`, + JSON.stringify(packageJsonContent) + ); + } } From e3af4628a77f9cd8c0b32b9e67ca83d92ec0a4a8 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 16 Mar 2026 11:08:44 +0200 Subject: [PATCH 173/223] test(project): Add cases for race condition (add/remove file) --- .../race-condition-add-file-task.js | 11 +++ .../race-condition-delete-file-task.js | 11 +++ .../ui5-race-condition-add-file.yaml | 17 +++++ .../ui5-race-condition-delete-file.yaml | 17 +++++ .../lib/build/ProjectBuilder.integration.js | 72 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 packages/project/test/fixtures/application.a/race-condition-add-file-task.js create mode 100644 packages/project/test/fixtures/application.a/race-condition-delete-file-task.js create mode 100644 packages/project/test/fixtures/application.a/ui5-race-condition-add-file.yaml create mode 100644 packages/project/test/fixtures/application.a/ui5-race-condition-delete-file.yaml diff --git a/packages/project/test/fixtures/application.a/race-condition-add-file-task.js b/packages/project/test/fixtures/application.a/race-condition-add-file-task.js new file mode 100644 index 00000000000..c4572b0aae1 --- /dev/null +++ b/packages/project/test/fixtures/application.a/race-condition-add-file-task.js @@ -0,0 +1,11 @@ +const {writeFile} = require("fs/promises"); +const path = require("path"); + +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + const webappPath = taskUtil.getProject().getSourcePath(); + const addedFilePath = path.join(webappPath, "added-during-build.js"); + await writeFile(addedFilePath, `console.log("RACE CONDITION ADDED FILE");\n`); +}; diff --git a/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js b/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js new file mode 100644 index 00000000000..bad0440f7c4 --- /dev/null +++ b/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js @@ -0,0 +1,11 @@ +const {rm} = require("fs/promises"); +const path = require("path"); + +module.exports = async function ({ + workspace, taskUtil, + options: {projectNamespace} +}) { + const webappPath = taskUtil.getProject().getSourcePath(); + const deletedFilePath = path.join(webappPath, "test.js"); + await rm(deletedFilePath, {force: true}); +}; diff --git a/packages/project/test/fixtures/application.a/ui5-race-condition-add-file.yaml b/packages/project/test/fixtures/application.a/ui5-race-condition-add-file.yaml new file mode 100644 index 00000000000..a222c8d80fd --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-race-condition-add-file.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: race-condition-add-file-task + afterTask: escapeNonAsciiCharacters +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: race-condition-add-file-task +task: + path: race-condition-add-file-task.js diff --git a/packages/project/test/fixtures/application.a/ui5-race-condition-delete-file.yaml b/packages/project/test/fixtures/application.a/ui5-race-condition-delete-file.yaml new file mode 100644 index 00000000000..18b27971769 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-race-condition-delete-file.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: race-condition-delete-file-task + afterTask: escapeNonAsciiCharacters +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: race-condition-delete-file-task +task: + path: race-condition-delete-file-task.js diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index ea58b6cebf8..2d6ba335655 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2054,6 +2054,8 @@ test.serial("Build race condition: file modified during active build", async (t) await fixtureTester._initialize(); const testFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; const originalContent = await fs.readFile(testFilePath, {encoding: "utf8"}); + const addedFileName = "added-during-build.js"; + const addedFilePath = `${fixtureTester.fixturePath}/webapp/${addedFileName}`; // #1 Build with race condition triggered by custom task // The custom task (configured in ui5-race-condition.yaml) modifies test.js during the build, @@ -2107,6 +2109,76 @@ test.serial("Build race condition: file modified during active build", async (t) finalBuiltContent.includes(`RACE CONDITION MODIFICATION`), "Build output incorrectly contains the modification due to corrupted cache" ); + + // #4 Build with race condition triggered by add-file custom task + await fs.rm(addedFilePath, {force: true}); + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-race-condition-add-file.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + const builtAddedFileContent = await fs.readFile(`${destPath}/${addedFileName}`, {encoding: "utf8"}); + t.true( + builtAddedFileContent.includes(`RACE CONDITION ADDED FILE`), + "Build output contains file added during active build" + ); + + // #5 Revert source state by removing the file that was added during build + await fs.rm(addedFilePath, {force: true}); + + // #6 Build again after removing the source file + // FIXME: The added file should trigger cache invalidation, but currently cache is reused. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-race-condition-add-file.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} // Current: cache reused | Expected: {"application.a": {}} + } + }); + + const staleAddedFileContent = await fs.readFile(`${destPath}/${addedFileName}`, {encoding: "utf8"}); + t.true( + staleAddedFileContent.includes(`RACE CONDITION ADDED FILE`), + "Build output incorrectly keeps added file due to corrupted cache" + ); + + // #7 Build with race condition triggered by delete-file custom task + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-race-condition-delete-file.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // File was deleted during build and therefore not part of the output + await t.throwsAsync(fs.readFile(`${destPath}/test.js`, {encoding: "utf8"})); + + // #8 Revert source state by restoring the deleted file + await fs.writeFile(testFilePath, originalContent); + + // #9 Build again after restoring the source file + // FIXME: The restored file should trigger cache invalidation, but currently cache is reused. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-race-condition-delete-file.yaml"}, + config: {destPath, cleanDest: true}, + assertions: { + projects: {} // Current: cache reused | Expected: {"application.a": {}} + } + }); + + const restoredBuiltFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true( + restoredBuiltFileContent.includes(`console.log`), + "Build output contains restored file after source recovery" + ); }); function getFixturePath(fixtureName) { From f89dbb70dfc08aa86dbba6bc7853e1504d66283a Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 11 Mar 2026 16:50:26 +0100 Subject: [PATCH 174/223] refactor(project): Cleanup cache state handling --- packages/project/lib/build/cache/ProjectBuildCache.js | 9 ++------- .../project/test/lib/build/ProjectBuilder.integration.js | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 9709ca995ca..4322db03646 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -834,13 +834,8 @@ export default class ProjectBuildCache { this.#taskCache.set(buildTaskCache.getTaskName(), buildTaskCache); } - if (changedPaths.length) { - // Relevant resources have changed, mark the cache as invalidated - // this.#resultCacheState = RESULT_CACHE_STATES.INVALIDATED; - } else { - // Source index is up-to-date, awaiting dependency indices validation - // Status remains at initializing - // this.#resultCacheState = RESULT_CACHE_STATES.INITIALIZING; + if (!changedPaths.length) { + // Source index is up-to-date with no changes this.#cachedSourceSignature = resourceIndex.getSignature(); } this.#sourceIndex = resourceIndex; diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 2d6ba335655..168edd173ad 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -489,7 +489,7 @@ test.serial("Build application.a (multiple custom tasks 2)", async (t) => { // Modify file to trigger a new build // (this is related to the custom tasks): - await fs.appendFile(`${fixtureTester.fixturePath}/webapp/test.js`, `console.log("NEW FILE");`); + await fs.appendFile(`${fixtureTester.fixturePath}/webapp/test.js`, `console.log("CHANGED FILE");`); // #2 build (with cache, with changes, with custom tasks) // During this build, "custom-task-0" sets a different tag to test.js (namely "OmitFromBuildResult"). From 29d8919b0afaac645a428801a85392c227bb01aa Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 10 Mar 2026 15:30:27 +0100 Subject: [PATCH 175/223] feat(project): Incorporate resource tags into hash tree leaf node hashes Tags (e.g. ui5:HasDebugVariant, ui5:IsBundle) set by earlier build tasks can affect later tasks. Include them in the hash tree leaf hash so that tag-only changes invalidate the cache signature correctly. --- .../project/lib/build/cache/index/HashTree.js | 57 ++++++-- .../lib/build/cache/index/SharedHashTree.js | 6 +- .../project/lib/build/cache/index/TreeNode.js | 20 ++- .../lib/build/cache/index/TreeRegistry.js | 33 ++++- packages/project/lib/build/cache/utils.js | 1 + .../project/lib/resources/ProjectResources.js | 2 +- .../test/lib/build/cache/index/HashTree.js | 127 ++++++++++++++++++ .../lib/build/cache/index/SharedHashTree.js | 4 +- 8 files changed, 228 insertions(+), 22 deletions(-) diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index dea4105c04a..3b9bab67e82 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -10,8 +10,28 @@ import {matchResourceMetadataStrict} from "../utils.js"; * @property {number} lastModified Last modification timestamp * @property {number|undefined} inode File inode identifier * @property {string} integrity Content hash + * @property {Object|null} [tags] Resource tags (key-value pairs) */ +/** + * Compare two tag objects for equality. + * Treats null, undefined, and empty {} as equivalent (no tags). + * + * @param {Object|null} a + * @param {Object|null} b + * @returns {boolean} + */ +export function tagsEqual(a, b) { + const aEmpty = !a || Object.keys(a).length === 0; + const bEmpty = !b || Object.keys(b).length === 0; + if (aEmpty && bEmpty) return true; + if (aEmpty !== bEmpty) return false; + const aKeys = Object.keys(a).sort(); + const bKeys = Object.keys(b).sort(); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key]); +} + /** * Directory-based Merkle Tree for efficient resource tracking with hierarchical structure. * @@ -142,7 +162,8 @@ export default class HashTree { integrity: resourceData.integrity, lastModified: resourceData.lastModified, size: resourceData.size, - inode: resourceData.inode + inode: resourceData.inode, + tags: resourceData.tags || null }); current.children.set(resourceName, resourceNode); @@ -157,6 +178,7 @@ export default class HashTree { * @param {number} [resourceData.lastModified] - Last modified timestamp * @param {number} [resourceData.size] - File size in bytes * @param {number} [resourceData.inode] - File system inode number + * @param {Object|null} [resourceData.tags] - Resource tags (key-value pairs) * @private */ _insertResource(resourcePath, resourceData) { @@ -189,7 +211,8 @@ export default class HashTree { integrity: resourceData.integrity, lastModified: resourceData.lastModified, size: resourceData.size, - inode: resourceData.inode + inode: resourceData.inode, + tags: resourceData.tags || null }); current.children.set(resourceName, resourceNode); @@ -198,14 +221,25 @@ export default class HashTree { /** * Compute hash for a node and all its children (recursive) * + * For resource nodes, the hash incorporates the resource name, integrity, and tags + * (when present). Tags are sorted by key for deterministic hashing. + * Resources with no tags (null, undefined, or empty {}) produce the same hash + * as tagless resources for backward compatibility. + * * @param {TreeNode} node * @returns {Buffer} * @private */ _computeHash(node) { if (node.type === "resource") { - // Resource hash - node.hash = this._hashData(`resource:${node.name}:${node.integrity}`); + // Resource hash — includes tags when present for cache invalidation + let hashInput = `resource:${node.name}:${node.integrity}`; + if (node.tags && Object.keys(node.tags).length > 0) { + const sortedKeys = Object.keys(node.tags).sort(); + const tagString = sortedKeys.map((k) => `${k}=${String(node.tags[k])}`).join(","); + hashInput += `:tags(${tagString})`; + } + node.hash = this._hashData(hashInput); } else { // Directory hash - compute from sorted children const childHashes = []; @@ -305,7 +339,8 @@ export default class HashTree { * Applies operations immediately with optimized hash recomputation. * * Automatically creates missing parent directories during insertion. - * Skips resources whose metadata hasn't changed (optimization). + * Skips resources whose metadata and tags haven't changed (optimization). + * A tag-only change (content unchanged but tags differ) is treated as an update. * * @param {Array<@ui5/fs/Resource>} resources - Array of Resource instances to upsert * @param {number} newIndexTimestamp Timestamp at which the provided resources have been indexed @@ -333,7 +368,8 @@ export default class HashTree { integrity: await resource.getIntegrity(), lastModified: resource.getLastModified(), size: await resource.getSize(), - inode: resource.getInode() + inode: resource.getInode(), + tags: resource.tags || null }; this._insertResource(resourcePath, resourceData); @@ -358,8 +394,12 @@ export default class HashTree { const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); if (isUnchanged) { - unchanged.push(resourcePath); - continue; + const currentTags = resource.tags || null; + if (tagsEqual(existingNode.tags, currentTags)) { + unchanged.push(resourcePath); + continue; + } + // Tags changed — fall through to update } // Update existing resource @@ -367,6 +407,7 @@ export default class HashTree { existingNode.lastModified = resource.getLastModified(); existingNode.size = await resource.getSize(); existingNode.inode = resource.getInode(); + existingNode.tags = resource.tags || null; this._computeHash(existingNode); updated.push(resourcePath); diff --git a/packages/project/lib/build/cache/index/SharedHashTree.js b/packages/project/lib/build/cache/index/SharedHashTree.js index e4c18514089..002479d7aba 100644 --- a/packages/project/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/lib/build/cache/index/SharedHashTree.js @@ -127,7 +127,8 @@ export default class SharedHashTree extends HashTree { integrity: node.integrity, size: node.size, lastModified: node.lastModified, - inode: node.inode + inode: node.inode, + tags: node.tags }); } } else { @@ -163,7 +164,8 @@ export default class SharedHashTree extends HashTree { integrity: node.integrity, size: node.size, lastModified: node.lastModified, - inode: node.inode + inode: node.inode, + tags: node.tags }); return; } else if (!baseNode && node.type === "directory") { diff --git a/packages/project/lib/build/cache/index/TreeNode.js b/packages/project/lib/build/cache/index/TreeNode.js index 130e9ef9152..d85c8b9071c 100644 --- a/packages/project/lib/build/cache/index/TreeNode.js +++ b/packages/project/lib/build/cache/index/TreeNode.js @@ -4,6 +4,18 @@ import path from "node:path/posix"; * Represents a node in the directory-based Merkle tree */ export default class TreeNode { + /** + * @param {string} name Resource name or directory name + * @param {"resource"|"directory"} type Node type + * @param {object} [options] + * @param {Buffer|null} [options.hash] Pre-computed hash + * @param {string} [options.integrity] Resource content hash + * @param {number} [options.lastModified] Last modified timestamp + * @param {number} [options.size] File size in bytes + * @param {number} [options.inode] File system inode number + * @param {Object|null} [options.tags] Resource tags (key-value pairs) + * @param {Map} [options.children] Child nodes (for directory nodes) + */ constructor(name, type, options = {}) { this.name = name; // resource name or directory name this.type = type; // 'resource' | 'directory' @@ -14,6 +26,7 @@ export default class TreeNode { this.lastModified = options.lastModified; // Last modified timestamp this.size = options.size; // File size in bytes this.inode = options.inode; // File system inode number + this.tags = options.tags || null; // Resource tags (key-value pairs) // Directory node properties this.children = options.children || new Map(); // name -> TreeNode @@ -46,6 +59,7 @@ export default class TreeNode { obj.lastModified = this.lastModified; obj.size = this.size; obj.inode = this.inode; + obj.tags = this.tags; } else { obj.children = {}; for (const [name, child] of this.children) { @@ -68,7 +82,8 @@ export default class TreeNode { integrity: data.integrity, lastModified: data.lastModified, size: data.size, - inode: data.inode + inode: data.inode, + tags: data.tags || null }; if (data.type === "directory" && data.children) { @@ -92,7 +107,8 @@ export default class TreeNode { integrity: this.integrity, lastModified: this.lastModified, size: this.size, - inode: this.inode + inode: this.inode, + tags: this.tags ? {...this.tags} : null }; if (this.type === "directory") { diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 2781d731c86..ffa4b485693 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -1,5 +1,6 @@ import path from "node:path/posix"; import TreeNode from "./TreeNode.js"; +import {tagsEqual} from "./HashTree.js"; import {matchResourceMetadataStrict} from "../utils.js"; /** @@ -159,7 +160,8 @@ export default class TreeRegistry { * - Group operations by parent directory for efficiency * - For inserts: only create in source tree and its derived trees * - For updates: apply to all trees that share the resource node - * - Skip updates for resources with unchanged metadata + * - Skip updates for resources with unchanged metadata and tags + * - Detect tag-only changes and treat them as updates * - Track modified nodes to avoid duplicate updates to shared nodes * * Phase 3: Recompute directory hashes @@ -313,7 +315,8 @@ export default class TreeRegistry { integrity: await upsert.resource.getIntegrity(), lastModified: upsert.resource.getLastModified(), size: await upsert.resource.getSize(), - inode: upsert.resource.getInode() + inode: upsert.resource.getInode(), + tags: upsert.resource.tags || null }); parentNode.children.set(upsert.resourceName, resourceNode); modifiedNodes.add(resourceNode); @@ -346,6 +349,7 @@ export default class TreeRegistry { resourceNode.lastModified = upsert.resource.getLastModified(); resourceNode.size = await upsert.resource.getSize(); resourceNode.inode = upsert.resource.getInode(); + resourceNode.tags = upsert.resource.tags ?? resourceNode.tags; modifiedNodes.add(resourceNode); dirModified = true; @@ -356,11 +360,26 @@ export default class TreeRegistry { updatedResources.push(upsert.fullPath); } } else { - // Track per-tree unchanged - treeStats.get(tree).unchanged.push(upsert.fullPath); - - if (!unchangedResources.includes(upsert.fullPath)) { - unchangedResources.push(upsert.fullPath); + const currentTags = upsert.resource.tags || null; + if (!tagsEqual(resourceNode.tags, currentTags)) { + // Tags changed — treat as update + resourceNode.tags = currentTags; + modifiedNodes.add(resourceNode); + dirModified = true; + + // Track per-tree update + treeStats.get(tree).updated.push(upsert.fullPath); + + if (!updatedResources.includes(upsert.fullPath)) { + updatedResources.push(upsert.fullPath); + } + } else { + // Track per-tree unchanged + treeStats.get(tree).unchanged.push(upsert.fullPath); + + if (!unchangedResources.includes(upsert.fullPath)) { + unchangedResources.push(upsert.fullPath); + } } } } else { diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index e6b93545d1c..63d317c45be 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -122,6 +122,7 @@ export async function createResourceIndex(resources, includeInode = false) { integrity: await resource.getIntegrity(), lastModified: resource.getLastModified(), size: await resource.getSize(), + tags: resource.getProject()?.getResourceTagCollection(resource).getAllTagsForResource(resource), }; if (includeInode) { resourceMetadata.inode = resource.getInode(); diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index 319db848c3e..32466cc94e3 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -362,7 +362,7 @@ class ProjectResources { getResourceTagCollection(resource, tag) { this.#applyCachedResourceTags(); const projectCollection = this.#getProjectResourceTagCollection(); - if (projectCollection.acceptsTag(tag)) { + if (!tag || projectCollection.acceptsTag(tag)) { if (!this.#monitoredProjectResourceTagCollection) { this.#monitoredProjectResourceTagCollection = new MonitoredResourceTagCollection(projectCollection); } diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index aa462840b69..7e6a6b3dac7 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -502,3 +502,130 @@ test("removeResources - cleans up deeply nested empty directories", async (t) => t.truthy(tree._findNode("a"), "Directory a should still exist (has sibling.js)"); t.truthy(tree.hasPath("a/sibling.js"), "Sibling file should still exist"); }); + +// ============================================================================ +// Resource Tags Tests +// ============================================================================ + +test("Different tags produce different root hashes", (t) => { + const resources1 = [ + {path: "file1.js", integrity: "hash1", tags: {"ui5:HasDebugVariant": true}} + ]; + + const resources2 = [ + {path: "file1.js", integrity: "hash1", tags: {"ui5:IsBundle": true}} + ]; + + const tree1 = new HashTree(resources1); + const tree2 = new HashTree(resources2); + + t.not(tree1.getRootHash(), tree2.getRootHash(), + "Trees with different tags should have different root hashes"); +}); + +test("Identical tags produce same root hash", (t) => { + const resources1 = [ + {path: "file1.js", integrity: "hash1", tags: {"ui5:HasDebugVariant": true, "ui5:IsBundle": false}} + ]; + + const resources2 = [ + {path: "file1.js", integrity: "hash1", tags: {"ui5:HasDebugVariant": true, "ui5:IsBundle": false}} + ]; + + const tree1 = new HashTree(resources1); + const tree2 = new HashTree(resources2); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Trees with identical tags should have same root hashes"); +}); + +test("No tags is backward compatible (null, undefined, {} all produce same hash)", (t) => { + const treeNull = new HashTree([{path: "file1.js", integrity: "hash1", tags: null}]); + const treeUndefined = new HashTree([{path: "file1.js", integrity: "hash1"}]); + const treeEmpty = new HashTree([{path: "file1.js", integrity: "hash1", tags: {}}]); + + t.is(treeNull.getRootHash(), treeUndefined.getRootHash(), + "null tags and undefined tags should produce same hash"); + t.is(treeNull.getRootHash(), treeEmpty.getRootHash(), + "null tags and empty tags should produce same hash"); +}); + +test("Tag key order does not affect hash", (t) => { + const resources1 = [ + {path: "file1.js", integrity: "hash1", tags: {"b": true, "a": true}} + ]; + + const resources2 = [ + {path: "file1.js", integrity: "hash1", tags: {"a": true, "b": true}} + ]; + + const tree1 = new HashTree(resources1); + const tree2 = new HashTree(resources2); + + t.is(tree1.getRootHash(), tree2.getRootHash(), + "Tag key order should not affect the hash"); +}); + +test("Upsert detects tag-only change (content unchanged, tags changed)", async (t) => { + const tree = new HashTree([ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100, tags: {"ui5:HasDebugVariant": true}} + ]); + const originalHash = tree.getRootHash(); + + // Upsert with same content but different tags + const resource = createMockResource("file1.js", "hash1", 1000, 100, 1); + resource.tags = {"ui5:HasDebugVariant": false}; + + const result = await tree.upsertResources([resource]); + + t.deepEqual(result.updated, ["file1.js"], "Should report resource as updated due to tag change"); + t.deepEqual(result.unchanged, [], "Should not report resource as unchanged"); + t.not(tree.getRootHash(), originalHash, "Root hash should change after tag-only update"); +}); + +test("Upsert reports unchanged when both content and tags are the same", async (t) => { + const tree = new HashTree([ + {path: "file1.js", integrity: "hash1", lastModified: 1000, size: 100, + tags: {"ui5:HasDebugVariant": true}} + ]); + const originalHash = tree.getRootHash(); + + const resource = createMockResource("file1.js", "hash1", 1000, 100, 1); + resource.tags = {"ui5:HasDebugVariant": true}; + + const result = await tree.upsertResources([resource]); + + t.deepEqual(result.unchanged, ["file1.js"], "Should report resource as unchanged"); + t.deepEqual(result.updated, [], "Should not report resource as updated"); + t.is(tree.getRootHash(), originalHash, "Root hash should not change"); +}); + +test("Serialization roundtrip preserves tags and root hash", (t) => { + const resources = [ + {path: "file1.js", integrity: "hash1", tags: {"ui5:HasDebugVariant": true, "ui5:IsBundle": false}}, + {path: "dir/file2.js", integrity: "hash2", tags: {"custom:tag": "value"}}, + {path: "file3.js", integrity: "hash3"} // no tags + ]; + + const tree = new HashTree(resources); + const originalHash = tree.getRootHash(); + + // Serialize and deserialize + const cacheObject = tree.toCacheObject(); + const restored = HashTree.fromCache(cacheObject); + + t.is(restored.getRootHash(), originalHash, + "Restored tree should have same root hash as original"); + + // Verify tags are preserved on individual nodes + const node1 = restored.getResourceByPath("file1.js"); + t.deepEqual(node1.tags, {"ui5:HasDebugVariant": true, "ui5:IsBundle": false}, + "Tags should be preserved after serialization roundtrip"); + + const node2 = restored.getResourceByPath("dir/file2.js"); + t.deepEqual(node2.tags, {"custom:tag": "value"}, + "Tags should be preserved for nested resources"); + + const node3 = restored.getResourceByPath("file3.js"); + t.is(node3.tags, null, "Null tags should be preserved"); +}); diff --git a/packages/project/test/lib/build/cache/index/SharedHashTree.js b/packages/project/test/lib/build/cache/index/SharedHashTree.js index d01073e21d0..2bc2f8e6eba 100644 --- a/packages/project/test/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/test/lib/build/cache/index/SharedHashTree.js @@ -482,8 +482,8 @@ test("getAddedResources - returns added resources from derived tree", (t) => { t.is(added.length, 2, "Should return 2 added resources"); t.deepEqual(added, [ - {path: "/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1}, - {path: "/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2} + {path: "/c.js", integrity: "hash-c", size: 100, lastModified: 1000, inode: 1, tags: null}, + {path: "/d.js", integrity: "hash-d", size: 200, lastModified: 2000, inode: 2, tags: null} ], "Should return correct added resources with metadata"); }); From 913100b249855d9e03725949a97de06e9cd46484 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 11 Mar 2026 16:49:31 +0100 Subject: [PATCH 176/223] refactor(fs): Add ResourceTagCollection#getAllTagsForResource method --- packages/fs/lib/MonitoredResourceTagCollection.js | 4 ++++ packages/fs/lib/ResourceTagCollection.js | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/fs/lib/MonitoredResourceTagCollection.js b/packages/fs/lib/MonitoredResourceTagCollection.js index cbc84fca26f..be96ba391b7 100644 --- a/packages/fs/lib/MonitoredResourceTagCollection.js +++ b/packages/fs/lib/MonitoredResourceTagCollection.js @@ -56,6 +56,10 @@ class MonitoredTagCollection { return this.#tagCollection.getTag(resourcePathOrResource, tag); } + getAllTagsForResource(resourcePath) { + return this.#tagCollection.getAllTagsForResource(resourcePath); + } + /** * Clear a tag from a resource and track the operation * diff --git a/packages/fs/lib/ResourceTagCollection.js b/packages/fs/lib/ResourceTagCollection.js index 19232e9aa75..a297f83dbac 100644 --- a/packages/fs/lib/ResourceTagCollection.js +++ b/packages/fs/lib/ResourceTagCollection.js @@ -96,6 +96,15 @@ class ResourceTagCollection { getAllTags() { return this._pathTags; } + /** + * Get all tags for all resources + * + * @param {string} resourcePath Path of the resource + * @returns {object} Object mapping tags to their values for the given resource path + */ + getAllTagsForResource(resourcePath) { + return this._pathTags[resourcePath] || Object.create(null); + } /** * Check if a tag is accepted by this collection From 0a297a263f7dee0978fcfb8ae811d4c5e2f81543 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 12 Mar 2026 17:20:16 +0100 Subject: [PATCH 177/223] refactor(project): Add test for cross-project resource tag handling --- .../project/lib/build/cache/index/HashTree.js | 6 +- packages/project/lib/build/cache/utils.js | 2 +- .../cross-project-tag-tasks/dep-tag-reader.js | 37 +++++++ .../cross-project-tag-tasks/dep-tag-setter.js | 22 +++++ .../ui5-crossProject-tagChange.yaml | 25 +++++ .../lib/build/ProjectBuilder.integration.js | 97 +++++++++++++++++++ 6 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js create mode 100644 packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js create mode 100644 packages/project/test/fixtures/application.a/ui5-crossProject-tagChange.yaml diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index 3b9bab67e82..88ac63b48c6 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -369,7 +369,7 @@ export default class HashTree { lastModified: resource.getLastModified(), size: await resource.getSize(), inode: resource.getInode(), - tags: resource.tags || null + tags: resource.getTags() }; this._insertResource(resourcePath, resourceData); @@ -394,7 +394,7 @@ export default class HashTree { const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); if (isUnchanged) { - const currentTags = resource.tags || null; + const currentTags = resource.getTags(); if (tagsEqual(existingNode.tags, currentTags)) { unchanged.push(resourcePath); continue; @@ -407,7 +407,7 @@ export default class HashTree { existingNode.lastModified = resource.getLastModified(); existingNode.size = await resource.getSize(); existingNode.inode = resource.getInode(); - existingNode.tags = resource.tags || null; + existingNode.tags = resource.getTags(); this._computeHash(existingNode); updated.push(resourcePath); diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 63d317c45be..03c86392815 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -122,7 +122,7 @@ export async function createResourceIndex(resources, includeInode = false) { integrity: await resource.getIntegrity(), lastModified: resource.getLastModified(), size: await resource.getSize(), - tags: resource.getProject()?.getResourceTagCollection(resource).getAllTagsForResource(resource), + tags: resource.getTags(), }; if (includeInode) { resourceMetadata.inode = resource.getInode(); diff --git a/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js new file mode 100644 index 00000000000..91ef3b68232 --- /dev/null +++ b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js @@ -0,0 +1,37 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:depTagReader"); + +let buildRanOnce; +module.exports = async function ({taskUtil, dependencies}) { + log.verbose("dep-tag-reader executed"); + + // const libraryDProject = taskUtil.getProject("library.d"); + const resources = await dependencies.byGlob("**/some.js"); + if (resources.length === 0) { + throw new Error("dep-tag-reader: some.js not found in library.d"); + } + const someJs = resources[0]; + + if (buildRanOnce !== true) { + log.verbose("First build: Verifying ui5:IsDebugVariant is set on some.js"); + buildRanOnce = true; + const tag = taskUtil.getTag(someJs, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (!tag) { + throw new Error( + "dep-tag-reader: Expected ui5:IsDebugVariant tag to be set on some.js in first build" + ); + } + } else { + log.verbose("Subsequent build: Verifying ui5:HasDebugVariant is set on some.js"); + const tag = taskUtil.getTag(someJs, taskUtil.STANDARD_TAGS.HasDebugVariant); + if (!tag) { + throw new Error( + "dep-tag-reader: Expected ui5:HasDebugVariant tag to be set on some.js in subsequent build" + ); + } + } +}; + +module.exports.determineRequiredDependencies = function ({availableDependencies}) { + return availableDependencies; +} diff --git a/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js new file mode 100644 index 00000000000..8d5b986dbd4 --- /dev/null +++ b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js @@ -0,0 +1,22 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:depTagSetter"); + +let buildRanOnce; +module.exports = async function ({workspace, taskUtil}) { + log.verbose("dep-tag-setter executed"); + + const resources = await workspace.byGlob("**/some.js"); + if (resources.length === 0) { + throw new Error("dep-tag-setter: some.js not found in workspace"); + } + const someJs = resources[0]; + + if (buildRanOnce !== true) { + log.verbose("First build: Setting ui5:IsDebugVariant on some.js"); + buildRanOnce = true; + taskUtil.setTag(someJs, taskUtil.STANDARD_TAGS.IsDebugVariant); + } else { + log.verbose("Subsequent build: Setting ui5:HasDebugVariant on some.js"); + taskUtil.setTag(someJs, taskUtil.STANDARD_TAGS.HasDebugVariant); + } +}; diff --git a/packages/project/test/fixtures/application.a/ui5-crossProject-tagChange.yaml b/packages/project/test/fixtures/application.a/ui5-crossProject-tagChange.yaml new file mode 100644 index 00000000000..595add44793 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-crossProject-tagChange.yaml @@ -0,0 +1,25 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: dep-tag-reader + afterTask: minify +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: dep-tag-setter +task: + path: cross-project-tag-tasks/dep-tag-setter.js +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: dep-tag-reader +task: + path: cross-project-tag-tasks/dep-tag-reader.js diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 168edd173ad..5a08c88efef 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -636,6 +636,103 @@ test.serial.skip("Build application.a (dependency content changes)", async (t) = t.true(builtFileContent2.includes(`console.log('something new');`), "Build dest contains changed file content"); }); +// FIXME: This test may fail until runtime tag handling bugs are fixed. +// It tests that a tag change on a dependency resource triggers a rebuild of the dependent project. +test.serial.skip("Build application.a (cross-project tag change)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + await fixtureTester._initialize(); + + // Modify library.d's ui5.yaml at runtime to add the dep-tag-setter custom task + const libraryDYamlPath = `${fixtureTester.fixturePath}/node_modules/library.d/ui5.yaml`; + await fs.writeFile(libraryDYamlPath, + `--- +specVersion: "2.3" +type: library +metadata: + name: library.d + copyright: Some fancy copyright +resources: + configuration: + paths: + src: main/src + test: main/test +builder: + customTasks: + - name: dep-tag-setter + afterTask: minify +`); + + // #1 build (no cache, with all dependencies) + // dep-tag-setter sets project:FirstBuild on some.js + // dep-tag-reader verifies project:FirstBuild is present + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // #2 build (cache, no changes) → all skipped + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); + + // Change source in library.d to trigger rebuild + const someJsPath = `${fixtureTester.fixturePath}/node_modules/library.d/main/src/library/d/some.js`; + await fs.appendFile(someJsPath, `\nconsole.log("tag change trigger");\n`); + + // #3 build (cache, library.d source changed) + // library.d rebuilt → dep-tag-setter now sets project:SubsequentBuild (different tag than #1) + // library.d's index signature changes due to the tag change + // application.a rebuilt because its dependency index changed + // dep-tag-reader verifies project:SubsequentBuild is present + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": { + // FIXME: skippedTasks need empirical determination once runtime bugs are fixed + skippedTasks: [ + "buildThemes", + "escapeNonAsciiCharacters", + "replaceBuildtime", + ] + }, + "application.a": { + // FIXME: skippedTasks need empirical determination once runtime bugs are fixed + skippedTasks: [ + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "replaceCopyright", + ] + }, + } + } + }); + + // #4 build (cache, no changes) → all skipped + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: {} + } + }); +}); + test.serial("Build application.a (JSDoc build)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; From 9ff89eca9eb451a0a31f15922e2ebf88db574bbe Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 12 Mar 2026 17:20:34 +0100 Subject: [PATCH 178/223] refactor(fs): Expose getTags method on resource This is meant as an internal method for now. Also fix retrieval of previously set tags for the purpose of determining the input state of a resource after a task has been executed --- packages/fs/lib/MonitoredResourceTagCollection.js | 6 ++++-- packages/fs/lib/Resource.js | 7 +++++++ packages/fs/lib/ResourceFacade.js | 4 ++++ packages/fs/lib/ResourceTagCollection.js | 15 ++++++++++++--- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/fs/lib/MonitoredResourceTagCollection.js b/packages/fs/lib/MonitoredResourceTagCollection.js index be96ba391b7..f23388e53d6 100644 --- a/packages/fs/lib/MonitoredResourceTagCollection.js +++ b/packages/fs/lib/MonitoredResourceTagCollection.js @@ -6,6 +6,7 @@ */ class MonitoredTagCollection { #tagCollection; + #previousTagCollecction; #tagOperations = new Map(); // resourcePath -> Map /** @@ -15,6 +16,7 @@ class MonitoredTagCollection { */ constructor(tagCollection) { this.#tagCollection = tagCollection; + this.#previousTagCollecction = tagCollection.clone(); } /** @@ -57,7 +59,7 @@ class MonitoredTagCollection { } getAllTagsForResource(resourcePath) { - return this.#tagCollection.getAllTagsForResource(resourcePath); + return this.#previousTagCollecction.getAllTagsForResource(resourcePath); } /** @@ -71,7 +73,7 @@ class MonitoredTagCollection { const resourcePath = this.#tagCollection._getPath(resourcePathOrResource); // Track cleared tags during this task's execution - const resourceTags = this.#tagOperations.has(resourcePath); + const resourceTags = this.#tagOperations.get(resourcePath); if (resourceTags) { resourceTags.set(tag, undefined); } diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index df5009bfe6b..07d1f7b67f1 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -882,6 +882,13 @@ class Resource { return tree; } + getTags() { + const project = this.getProject(); + const collection = project?.getResourceTagCollection(this); + const tags = collection?.getAllTagsForResource(this) || null; + return tags; + } + /** * Returns source metadata which may contain information specific to the adapter that created the resource * Typically set by an adapter to store information for later retrieval. diff --git a/packages/fs/lib/ResourceFacade.js b/packages/fs/lib/ResourceFacade.js index fe549e7ac3c..fb883fb9e2f 100644 --- a/packages/fs/lib/ResourceFacade.js +++ b/packages/fs/lib/ResourceFacade.js @@ -272,6 +272,10 @@ class ResourceFacade { return this.#resource.getPathTree(); } + getTags() { + return this.#resource.getTags(); + } + /** * Retrieve the project assigned to the resource *
diff --git a/packages/fs/lib/ResourceTagCollection.js b/packages/fs/lib/ResourceTagCollection.js index a297f83dbac..712c2eb27af 100644 --- a/packages/fs/lib/ResourceTagCollection.js +++ b/packages/fs/lib/ResourceTagCollection.js @@ -99,11 +99,12 @@ class ResourceTagCollection { /** * Get all tags for all resources * - * @param {string} resourcePath Path of the resource - * @returns {object} Object mapping tags to their values for the given resource path + * @param {string|object} resourcePath Path of the resource + * @returns {object|null} Object mapping tags to their values for the given resource path */ getAllTagsForResource(resourcePath) { - return this._pathTags[resourcePath] || Object.create(null); + resourcePath = this._getPath(resourcePath); + return this._pathTags[resourcePath] || null; } /** @@ -184,6 +185,14 @@ class ResourceTagCollection { `Invalid Tag Value: Must be of type string, number or boolean but is ${type}`); } } + + clone() { + return new ResourceTagCollection({ + allowedTags: this._allowedTags, + allowedNamespaces: this._allowedNamespaces, + tags: JSON.parse(JSON.stringify(this._pathTags)) + }); + } } export default ResourceTagCollection; From 691b8b4f3b8f40bc0b3fbfe29e9ffcb5b42a16f9 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 13 Mar 2026 10:12:28 +0100 Subject: [PATCH 179/223] refactor(project): Fix tag handling, align test --- .../lib/build/cache/ProjectBuildCache.js | 26 ++++++++++- .../lib/build/cache/index/TreeRegistry.js | 6 +-- .../project/lib/resources/ProjectResources.js | 46 ++++++++++++------- .../lib/build/ProjectBuilder.integration.js | 26 +++++++++-- .../test/lib/build/cache/BuildTaskCache.js | 3 +- .../test/lib/build/cache/ProjectBuildCache.js | 1 + .../lib/build/cache/ResourceRequestManager.js | 1 + .../test/lib/build/cache/index/HashTree.js | 9 +++- .../lib/build/cache/index/SharedHashTree.js | 3 +- .../lib/build/cache/index/TreeRegistry.js | 3 +- 10 files changed, 93 insertions(+), 31 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 4322db03646..c667d7a9d48 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -623,12 +623,34 @@ export default class ProjectBuildCache { const stageWriter = stage.getWriter(); const writtenResources = await stageWriter.byGlob("/**/*"); const writtenResourcePaths = writtenResources.map((res) => res.getOriginalPath()); - const {projectTagOperations, buildTagOperations} = + let {projectTagOperations, buildTagOperations} = this.#project.getProjectResources().getResourceTagOperations(); let stageSignature; if (cacheInfo) { - // TODO: Update + // Merge tag operations from the previous stage cache with the current delta's tag operations. + // Delta builds only record tags set during the delta execution, so we need to include + // tags from the original full build. Current delta ops take precedence over previous ops. + if (cacheInfo.previousStageCache.projectTagOperations) { + projectTagOperations = new Map([ + ...cacheInfo.previousStageCache.projectTagOperations, + ...projectTagOperations, + ]); + } + if (cacheInfo.previousStageCache.buildTagOperations) { + buildTagOperations = new Map([ + ...cacheInfo.previousStageCache.buildTagOperations, + ...buildTagOperations, + ]); + } + + // Import the previous stage cache's tag operations into the tag collections so that + // subsequent tasks can access them. Delta builds only record tags set during delta + // execution, so the previous build's tags must be imported explicitly. + this.#project.getProjectResources().importTagOperations( + cacheInfo.previousStageCache.projectTagOperations, + cacheInfo.previousStageCache.buildTagOperations); + stageSignature = cacheInfo.newSignature; // Add resources from previous stage cache to current stage let reader; diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index ffa4b485693..5b69fb11f36 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -316,7 +316,7 @@ export default class TreeRegistry { lastModified: upsert.resource.getLastModified(), size: await upsert.resource.getSize(), inode: upsert.resource.getInode(), - tags: upsert.resource.tags || null + tags: upsert.resource.getTags?.() ?? upsert.resource.tags ?? null }); parentNode.children.set(upsert.resourceName, resourceNode); modifiedNodes.add(resourceNode); @@ -349,7 +349,7 @@ export default class TreeRegistry { resourceNode.lastModified = upsert.resource.getLastModified(); resourceNode.size = await upsert.resource.getSize(); resourceNode.inode = upsert.resource.getInode(); - resourceNode.tags = upsert.resource.tags ?? resourceNode.tags; + resourceNode.tags = upsert.resource.getTags?.() ?? upsert.resource.tags ?? resourceNode.tags; modifiedNodes.add(resourceNode); dirModified = true; @@ -360,7 +360,7 @@ export default class TreeRegistry { updatedResources.push(upsert.fullPath); } } else { - const currentTags = upsert.resource.tags || null; + const currentTags = upsert.resource.getTags?.() ?? upsert.resource.tags ?? null; if (!tagsEqual(resourceNode.tags, currentTags)) { // Tags changed — treat as update resourceNode.tags = currentTags; diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index 32466cc94e3..365576d2541 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -20,7 +20,6 @@ class ProjectResources { #currentStage; #currentStageReadIndex; #lastTagCacheImportIndex; - #currentTagCacheImportIndex; #currentStageId; // Cache @@ -201,7 +200,6 @@ class ProjectResources { this.#currentStage = null; this.#currentStageId = RESULT_STAGE_ID; this.#currentStageReadIndex = this.#stages.length - 1; // Read from all stages - this.#currentTagCacheImportIndex = this.#stages.length - 1; // Import cached tags from all stages // Unset "current" reader/writer. They will be recreated on demand this.#currentStageReaders = new Map(); @@ -218,7 +216,6 @@ class ProjectResources { this.#currentStageId = INITIAL_STAGE_ID; this.#currentStageReadIndex = -1; this.#lastTagCacheImportIndex = -1; - this.#currentTagCacheImportIndex = -1; this.#currentStageReaders = new Map(); this.#currentStageWorkspace = null; this.#projectResourceTagCollection = null; @@ -284,7 +281,6 @@ class ProjectResources { this.#currentStage = stage; this.#currentStageId = stageId; this.#currentStageReadIndex = stageIdx - 1; // Read from all previous stages - this.#currentTagCacheImportIndex = stageIdx; // Import cached tags from previous and current stages // Unset "current" reader/writer caches. They will be recreated on demand this.#currentStageReaders = new Map(); @@ -391,6 +387,34 @@ class ProjectResources { }; } + /** + * Imports tag operations into the underlying tag collections. + * + * This is used during delta builds to apply tag operations from a previous stage cache + * that would otherwise be lost because the delta execution only records its own tag operations. + * + * @param {Map>} [projectTagOperations] + * @param {Map>} [buildTagOperations] + */ + importTagOperations(projectTagOperations, buildTagOperations) { + if (projectTagOperations?.size) { + const projectTagCollection = this.#getProjectResourceTagCollection(); + for (const [resourcePath, tags] of projectTagOperations.entries()) { + for (const [tag, value] of tags.entries()) { + projectTagCollection.setTag(resourcePath, tag, value); + } + } + } + if (buildTagOperations?.size) { + const buildTagCollection = this.#getBuildResourceTagCollection(); + for (const [resourcePath, tags] of buildTagOperations.entries()) { + for (const [tag, value] of tags.entries()) { + buildTagCollection.setTag(resourcePath, tag, value); + } + } + } + } + /** * Returns the project-level resource tag collection. * @@ -431,7 +455,7 @@ class ProjectResources { const cachedProjectTagOps = []; const cachedBuildTagOps = []; - for (let i = this.#lastTagCacheImportIndex + 1; i <= this.#currentTagCacheImportIndex; i++) { + for (let i = this.#lastTagCacheImportIndex + 1; i <= this.#currentStageReadIndex; i++) { const projectTagOps = this.#stages[i].getCachedProjectTagOperations(); if (projectTagOps) { cachedProjectTagOps.push(projectTagOps); @@ -442,17 +466,7 @@ class ProjectResources { } } - // if (this.#currentStage) { - // const projectTagOps = this.#currentStage.getCachedProjectTagOperations(); - // if (projectTagOps) { - // cachedProjectTagOps.push(projectTagOps); - // } - // const buildTagOps = this.#currentStage.getCachedBuildTagOperations(); - // if (buildTagOps) { - // cachedBuildTagOps.push(buildTagOps); - // } - // } - this.#lastTagCacheImportIndex = this.#currentTagCacheImportIndex; + this.#lastTagCacheImportIndex = this.#currentStageReadIndex; const projectTagOps = mergeMaps(...cachedProjectTagOps); const buildTagOps = mergeMaps(...cachedBuildTagOps); diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 5a08c88efef..695104b702b 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -638,7 +638,7 @@ test.serial.skip("Build application.a (dependency content changes)", async (t) = // FIXME: This test may fail until runtime tag handling bugs are fixed. // It tests that a tag change on a dependency resource triggers a rebuild of the dependent project. -test.serial.skip("Build application.a (cross-project tag change)", async (t) => { +test.serial("Build application.a (cross-project tag change)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; await fixtureTester._initialize(); @@ -668,7 +668,10 @@ builder: // dep-tag-reader verifies project:FirstBuild is present await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, - config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + config: { + destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}, + excludedTasks: ["minify"], + }, assertions: { projects: { "library.d": {}, @@ -683,7 +686,10 @@ builder: // #2 build (cache, no changes) → all skipped await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, - config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + config: { + destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}, + excludedTasks: ["minify"], + }, assertions: { projects: {} } @@ -700,13 +706,17 @@ builder: // dep-tag-reader verifies project:SubsequentBuild is present await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, - config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + config: { + destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}, + excludedTasks: ["minify"], + }, assertions: { projects: { "library.d": { // FIXME: skippedTasks need empirical determination once runtime bugs are fixed skippedTasks: [ "buildThemes", + "enhanceManifest", "escapeNonAsciiCharacters", "replaceBuildtime", ] @@ -714,9 +724,12 @@ builder: "application.a": { // FIXME: skippedTasks need empirical determination once runtime bugs are fixed skippedTasks: [ + "enhanceManifest", "escapeNonAsciiCharacters", + "generateComponentPreload", "generateFlexChangesBundle", "replaceCopyright", + "replaceVersion", ] }, } @@ -726,7 +739,10 @@ builder: // #4 build (cache, no changes) → all skipped await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-crossProject-tagChange.yaml"}, - config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + config: { + destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}, + excludedTasks: ["minify"], + }, assertions: { projects: {} } diff --git a/packages/project/test/lib/build/cache/BuildTaskCache.js b/packages/project/test/lib/build/cache/BuildTaskCache.js index a72a9cc234d..85f4052e3cf 100644 --- a/packages/project/test/lib/build/cache/BuildTaskCache.js +++ b/packages/project/test/lib/build/cache/BuildTaskCache.js @@ -29,7 +29,8 @@ function createMockResource(path, content = "test content", hash = null) { getIntegrity: async () => actualHash, getLastModified: () => 1000, getSize: async () => content.length, - getInode: () => 1 + getInode: () => 1, + getTags: () => null }; } diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 93f05251069..933625b8578 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -76,6 +76,7 @@ function createMockResource(path, integrity = "test-hash", lastModified = 1000, getLastModified: () => lastModified, getSize: async () => size, getInode: () => inode, + getTags: () => null, getBuffer: async () => Buffer.from("test content"), getStream: () => null }; diff --git a/packages/project/test/lib/build/cache/ResourceRequestManager.js b/packages/project/test/lib/build/cache/ResourceRequestManager.js index 7bedc40abaa..161b292ac2e 100644 --- a/packages/project/test/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/test/lib/build/cache/ResourceRequestManager.js @@ -12,6 +12,7 @@ function createMockResource(path, integrity = "test-hash", lastModified = 1000, getLastModified: () => lastModified, getSize: async () => size, getInode: () => inode, + getTags: () => null, getBuffer: async () => Buffer.from("test content"), getStream: () => null }; diff --git a/packages/project/test/lib/build/cache/index/HashTree.js b/packages/project/test/lib/build/cache/index/HashTree.js index 7e6a6b3dac7..1ae9d93f32b 100644 --- a/packages/project/test/lib/build/cache/index/HashTree.js +++ b/packages/project/test/lib/build/cache/index/HashTree.js @@ -4,13 +4,18 @@ import HashTree from "../../../../../lib/build/cache/index/HashTree.js"; // Helper to create mock Resource instances function createMockResource(path, integrity, lastModified, size, inode) { - return { + const resource = { + tags: null, getOriginalPath: () => path, getIntegrity: async () => integrity, getLastModified: () => lastModified, getSize: async () => size, - getInode: () => inode + getInode: () => inode, + getTags() { + return this.tags; + } }; + return resource; } test.afterEach.always((t) => { diff --git a/packages/project/test/lib/build/cache/index/SharedHashTree.js b/packages/project/test/lib/build/cache/index/SharedHashTree.js index 2bc2f8e6eba..131306b7ee4 100644 --- a/packages/project/test/lib/build/cache/index/SharedHashTree.js +++ b/packages/project/test/lib/build/cache/index/SharedHashTree.js @@ -10,7 +10,8 @@ function createMockResource(path, integrity, lastModified, size, inode) { getIntegrity: async () => integrity, getLastModified: () => lastModified, getSize: async () => size, - getInode: () => inode + getInode: () => inode, + getTags: () => null }; } diff --git a/packages/project/test/lib/build/cache/index/TreeRegistry.js b/packages/project/test/lib/build/cache/index/TreeRegistry.js index d4e116a7cfd..f7f0b5e6b7a 100644 --- a/packages/project/test/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/test/lib/build/cache/index/TreeRegistry.js @@ -10,7 +10,8 @@ function createMockResource(path, integrity, lastModified, size, inode) { getIntegrity: async () => integrity, getLastModified: () => lastModified, getSize: async () => size, - getInode: () => inode + getInode: () => inode, + getTags: () => null }; } From 4afc5847e6e0afbedd3690b382a9129e53ab5e5c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 16 Mar 2026 15:13:59 +0100 Subject: [PATCH 180/223] refactor(project): Improve build abort handling, ease race-condition test --- packages/project/lib/build/ProjectBuilder.js | 2 +- packages/project/lib/build/TaskRunner.js | 1 + .../test/lib/build/BuildServer.integration.js | 23 +++++++++++-------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 4af522746ff..45e418787e0 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -323,7 +323,7 @@ class ProjectBuilder { this.#log.skipProjectBuild(projectName, projectType); alreadyBuilt.push(projectName); } else { - await this._buildProject(projectBuildContext); + await this._buildProject(projectBuildContext, signal); } } signal?.throwIfAborted(); diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 1587b143525..dff391631ce 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -141,6 +141,7 @@ class TaskRunner { await this._executeTask(taskName, taskFunction); } } + signal?.throwIfAborted(); return await this._buildCache.allTasksCompleted(); } diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index f4e41780eb5..6039881410b 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -51,22 +51,25 @@ test.serial("Serve application.a, initial file changes", async (t) => { // Request the changed resource immediately const resourceRequestPromise = fixtureTester.requestResource({ - resource: "/test.js", - assertions: { - projects: { - "application.a": {} - } - } + resource: "/test.js" }); + + await setTimeout(500); + // Directly change the source file again, which should abort the current build and trigger a new one await fs.appendFile(changedFilePath, `\ntest("second change");\n`); await fs.appendFile(changedFilePath, `\ntest("third change");\n`); // Wait for the resource to be served - const resource = await resourceRequestPromise; + await resourceRequestPromise; + await setTimeout(500); + + const resource2 = await fixtureTester.requestResource({ + resource: "/test.js" + }); // Check whether the change is reflected - const servedFileContent = await resource.getString(); + const servedFileContent = await resource2.getString(); t.true(servedFileContent.includes(`test("initial change");`), "Resource contains initial changed file content"); t.true(servedFileContent.includes(`test("second change");`), "Resource contains second changed file content"); t.true(servedFileContent.includes(`test("third change");`), "Resource contains third changed file content"); @@ -409,7 +412,7 @@ class FixtureTester { this._reader = this.buildServer.getReader(); } - async requestResource({resource, assertions = {}}) { + async requestResource({resource, assertions}) { this._sinon.resetHistory(); const res = await this._reader.byPath(resource); // Apply assertions if provided @@ -419,7 +422,7 @@ class FixtureTester { return res; } - async requestResources({resources, assertions = {}}) { + async requestResources({resources, assertions}) { this._sinon.resetHistory(); const returnedResources = await Promise.all(resources.map((resource) => this._reader.byPath(resource))); // Apply assertions if provided From 1e7f3767a2f4bf7c50b53ff2e646710fb6c7527c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 16 Mar 2026 16:13:11 +0100 Subject: [PATCH 181/223] refactor(project): Cleanup comment --- packages/project/test/lib/build/ProjectBuilder.integration.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 695104b702b..7618e30992c 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -636,8 +636,6 @@ test.serial.skip("Build application.a (dependency content changes)", async (t) = t.true(builtFileContent2.includes(`console.log('something new');`), "Build dest contains changed file content"); }); -// FIXME: This test may fail until runtime tag handling bugs are fixed. -// It tests that a tag change on a dependency resource triggers a rebuild of the dependent project. test.serial("Build application.a (cross-project tag change)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; From eecf9a89229792912dc2ccf429f600980c1dd2d1 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Mon, 16 Mar 2026 17:28:50 +0100 Subject: [PATCH 182/223] test(project): Add case for --include-dependency (Cover build with only some dependencies, not all) `+` Fix some typos in comments --- .../lib/build/ProjectBuilder.integration.js | 97 +++++++++++++++++-- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 7618e30992c..ab0adf19ba4 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -118,7 +118,7 @@ test.serial("Build application.a project multiple times", async (t) => { }); - // #6 build (with cache, no changes, with custom tasks) + // #7 build (with cache, no changes, with custom tasks) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, config: {destPath, cleanDest: true}, @@ -130,7 +130,7 @@ test.serial("Build application.a project multiple times", async (t) => { }); - // #7 build (with cache, no changes, with custom tasks) + // #8 build (with cache, no changes, with custom tasks) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-customTask.yaml"}, config: {destPath, cleanDest: true}, @@ -140,7 +140,7 @@ test.serial("Build application.a project multiple times", async (t) => { }); - // #8 build (with cache, no changes, with dependencies) + // #9 build (with cache, no changes, with dependencies) await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { @@ -170,7 +170,7 @@ test.serial("Build application.a project multiple times", async (t) => { ) ); - // #9 build (with cache, with changes) + // #10 build (with cache, with changes) await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -193,8 +193,8 @@ test.serial("Build application.a project multiple times", async (t) => { `console.log("SOME NEW CONTENT");\n` ); - // #10 build (with cache, with changes - someNew.js added) - // Tasks that don't depend on someNew.js can reuse their caches from build #9. + // #11 build (with cache, with changes - someNew.js added) + // Tasks that don't depend on someNew.js can reuse their caches from build #10. await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -211,8 +211,8 @@ test.serial("Build application.a project multiple times", async (t) => { await fs.rm(`${fixtureTester.fixturePath}/webapp/someNew.js`); - // #11 build (with cache, with changes - someNew.js removed) - // Source state matches build #9's cached result -> cache reused, everything skipped + // #12 build (with cache, with changes - someNew.js removed) + // Source state matches build #10's cached result -> cache reused, everything skipped await fixtureTester.buildProject({ config: {destPath, cleanDest: true}, assertions: { @@ -337,6 +337,87 @@ test.serial("Build application.a (with various dependencies)", async (t) => { }); }); +test.serial("Build application.a (including only some dependencies)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // In this test, we're testing the "dependencyIncludes" build option + // which allows to include only a subset of the dependencies of a project in the build. + // "application.a" has 4 dependencies defined: library.a, library.b, library.c and library.d. + + // #1 build + // Only include library.a and library.b as dependencies, but not library.c and library.d: + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, + dependencyIncludes: {includeDependency: ["library.a", "library.b"]}}, + assertions: { + projects: { + "library.a": {}, + "library.b": {}, + "application.a": {} + } + } + }); + + // Check that only the included dependencies are in the destPath: + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/a/library-preload.js`, + {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/b/library-preload.js`, + {encoding: "utf8"})); + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/c/library-preload.js`, + {encoding: "utf8"})); + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/library-preload.js`, + {encoding: "utf8"})); + + + // #2 build + // Exclude library.d as dependency, but include all other dependencies + // (builds of library.a and library.b can be reused from cache): + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, + dependencyIncludes: {includeAllDependencies: true, excludeDependency: ["library.d"]}}, + assertions: { + projects: { + "library.c": {}, + } + } + }); + + // Check that only the included dependencies are in the destPath: + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/a/library-preload.js`, + {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/b/library-preload.js`, + {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/c/library-preload.js`, + {encoding: "utf8"})); + await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/library-preload.js`, + {encoding: "utf8"})); + + + // #3 build + // Include all dependencies (only library.d is built) + // (builds of library.a, library.b, and library.c can be reused from cache): + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + } + } + }); + + // Check that all dependencies are in the destPath: + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/a/library-preload.js`, + {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/b/library-preload.js`, + {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/c/library-preload.js`, + {encoding: "utf8"})); + await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/d/library-preload.js`, + {encoding: "utf8"})); +}); + test.serial("Build application.a (custom task and tag handling)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; From 5d96e188cb6847507f7278075941e0f86ffd6b97 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 18 Mar 2026 09:31:31 +0200 Subject: [PATCH 183/223] test(project): Check if sap-ui-version.json contains correct content --- .../lib/build/ProjectBuilder.integration.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index ab0adf19ba4..8de8c3be0ac 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2373,6 +2373,75 @@ test.serial("Build race condition: file modified during active build", async (t) ); }); +test.serial("Build with dependencies: Verify sap-ui-version.json generation and regeneration", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + const versionInfoPath = `${destPath}/resources/sap-ui-version.json`; + + // Build #1: Full build with all dependencies in JSDoc mode + // JSDoc mode enables generateVersionInfo task which creates sap-ui-version.json + await fixtureTester.buildProject({ + config: { + destPath, + cleanDest: true, + jsdoc: "jsdoc", + dependencyIncludes: {includeAllDependencies: true} + }, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + const versionInfo1Content = await fs.readFile(versionInfoPath, {encoding: "utf8"}); + t.truthy(versionInfo1Content, "sap-ui-version.json should exist"); + const versionInfo1 = JSON.parse(versionInfo1Content); + + // Root project metadata + t.is(versionInfo1.name, "application.a", "Root project name"); + t.is(versionInfo1.version, "1.0.0", "Root project version"); + t.is(typeof versionInfo1.buildTimestamp, "string", "buildTimestamp is string"); + + // Libraries array + t.true(Array.isArray(versionInfo1.libraries), "libraries is array"); + const libraryNames = versionInfo1.libraries.map((lib) => lib.name).sort(); + t.deepEqual(libraryNames, ["library.a", "library.b", "library.c", "library.d"], + "Contains all dependency libraries"); + + // Each library has required fields + versionInfo1.libraries.forEach((lib) => { + t.is(typeof lib.name, "string", `Library ${lib.name} has name`); + t.is(typeof lib.version, "string", `Library ${lib.name} has version`); + t.is(typeof lib.buildTimestamp, "string", `Library ${lib.name} has buildTimestamp`); + }); + + const firstBuildTimestamp = versionInfo1.buildTimestamp; + + // Build #2: No changes, expect full cache hit + await fixtureTester.buildProject({ + config: { + destPath, + cleanDest: true, + jsdoc: "jsdoc", + dependencyIncludes: {includeAllDependencies: true} + }, + assertions: { + projects: {} // All projects cached + } + }); + + // Verify sap-ui-version.json was reused from cache (timestamp unchanged) + const versionInfo2Content = await fs.readFile(versionInfoPath, {encoding: "utf8"}); + const versionInfo2 = JSON.parse(versionInfo2Content); + t.is(versionInfo2.buildTimestamp, firstBuildTimestamp, + "buildTimestamp unchanged when cached (no source changes)"); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } From 4f7cd30cf0c45668fa29cafd44f8135ef7b8b6f3 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 19 Mar 2026 15:28:20 +0100 Subject: [PATCH 184/223] test(project): Add case for removing a dependency `+` Extend FixtureTester to assert seen projects (not only built projects) `+` Fix some copy/paste leftovers ("cleanDest: false") `+` Replace console.log with @ui5/logger Log --- .../custom-tasks/custom-task-0.js | 5 +- .../custom-tasks/custom-task-1.js | 5 +- .../custom-tasks/custom-task-2.js | 5 +- .../fixtures/application.a/task.example.js | 5 +- .../lib/build/ProjectBuilder.integration.js | 81 ++++++++++++++----- 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js index 753a5fbc1e9..c19f110113c 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js @@ -1,8 +1,11 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:customTask0"); + module.exports = async function ({ workspace, taskUtil, options: {projectNamespace} }) { - console.log("Custom task 0 executed"); + log.verbose("Custom task 0 executed"); // Read a file which is an input of custom-task-1 (which sets a tag on it): const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js index a2a992c9653..58030f3ea06 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js @@ -1,8 +1,11 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:customTask1"); + module.exports = async function ({ workspace, taskUtil, options: {projectNamespace} }) { - console.log("Custom task 1 executed"); + log.verbose("Custom task 1 executed"); // Set a tag on a specific resource: const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js index 5cb3723e5f1..de5a39068a2 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js @@ -1,8 +1,11 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:customTask2"); + module.exports = async function ({ workspace, taskUtil, options: {projectNamespace} }) { - console.log("Custom task 2 executed"); + log.verbose("Custom task 2 executed"); // Read a file which is an input of custom-task-1 (which sets a tag on it): const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); diff --git a/packages/project/test/fixtures/application.a/task.example.js b/packages/project/test/fixtures/application.a/task.example.js index efc4d0f12d9..669c19c3fbf 100644 --- a/packages/project/test/fixtures/application.a/task.example.js +++ b/packages/project/test/fixtures/application.a/task.example.js @@ -1,8 +1,11 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:exampleTask"); + module.exports = async function ({ workspace, taskUtil, options: {projectNamespace} }) { - console.log("Example task executed"); + log.verbose("Example task executed"); // Omit a specific resource from the build result const omittedResource = await workspace.byPath(`/resources/${projectNamespace}/fileToBeOmitted.js`); diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 8de8c3be0ac..073c145ff50 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -254,7 +254,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { // #3 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "component.z": {}, @@ -276,7 +276,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { // #4 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "library.z": {}, @@ -298,7 +298,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { // #5 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "themelib.z": {}, @@ -320,7 +320,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { // #6 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "module.z": {}, @@ -374,7 +374,7 @@ test.serial("Build application.a (including only some dependencies)", async (t) // Exclude library.d as dependency, but include all other dependencies // (builds of library.a and library.b can be reused from cache): await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true, excludeDependency: ["library.d"]}}, assertions: { projects: { @@ -398,7 +398,7 @@ test.serial("Build application.a (including only some dependencies)", async (t) // Include all dependencies (only library.d is built) // (builds of library.a, library.b, and library.c can be reused from cache): await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { @@ -416,6 +416,26 @@ test.serial("Build application.a (including only some dependencies)", async (t) {encoding: "utf8"})); await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/d/library-preload.js`, {encoding: "utf8"})); + + + // Delete a dependency ("library.d") from application.a: + await fs.rm(`${fixtureTester.fixturePath}/node_modules/library.d`, {recursive: true, force: true}); + const packageJsonContent = JSON.parse( + await fs.readFile(`${fixtureTester.fixturePath}/package.json`, {encoding: "utf8"})); + delete packageJsonContent.dependencies["library.d"]; + await fs.writeFile(`${fixtureTester.fixturePath}/package.json`, JSON.stringify(packageJsonContent, null, 2)); + + // #4 build + // Build application.a again with "includeAllDependencies" + // and check with assertion "allProjects" that "library.d" isn't even seen: + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + allProjects: ["library.a", "library.b", "library.c", "application.a"], + projects: {}, // no project should be rebuilt + } + }); }); test.serial("Build application.a (custom task and tag handling)", async (t) => { @@ -1223,7 +1243,7 @@ test.serial("Build application.a (Custom bundling)", async (t) => { // #5 build with custom bundle configuration (with empty cache) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, - config: {destPath, cleanDest: false}, + config: {destPath, cleanDest: true}, assertions: { projects: { "application.a": {} @@ -1258,7 +1278,7 @@ test.serial("Build application.a (Custom bundling)", async (t) => { // #6 build with custom bundle configuration (with empty cache) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, - config: {destPath, cleanDest: false}, + config: {destPath, cleanDest: true}, assertions: { projects: { "application.a": {} @@ -1297,7 +1317,7 @@ test.serial("Build application.a (Custom bundling)", async (t) => { // #7 build with custom bundle configuration (with empty cache) await fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-custom-bundling.yaml"}, - config: {destPath, cleanDest: false}, + config: {destPath, cleanDest: true}, assertions: { projects: { "application.a": {} @@ -1495,7 +1515,7 @@ test.serial("Build library.d (with various dependencies)", async (t) => { // #3 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "library.z": {}, @@ -1517,7 +1537,7 @@ test.serial("Build library.d (with various dependencies)", async (t) => { // #4 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "themelib.z": {}, @@ -1748,7 +1768,7 @@ test.serial("Build theme.library.e (with various dependencies)", async (t) => { // #3 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "library.z": {}, @@ -1913,7 +1933,7 @@ test.serial("Build component.a (with various dependencies)", async (t) => { // #3 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "component.z": {}, @@ -1935,7 +1955,7 @@ test.serial("Build component.a (with various dependencies)", async (t) => { // #4 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "library.z": {}, @@ -1957,7 +1977,7 @@ test.serial("Build component.a (with various dependencies)", async (t) => { // #5 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "themelib.z": {}, @@ -1979,7 +1999,7 @@ test.serial("Build component.a (with various dependencies)", async (t) => { // #6 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "module.z": {}, @@ -2230,7 +2250,7 @@ test.serial("Build module.b (with various dependencies)", async (t) => { // #4 build (no cache, with changes, with dependencies) await fixtureTester.buildProject({ - config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { projects: { "themelib.z": {}, @@ -2495,7 +2515,22 @@ class FixtureTester { } _assertBuild(assertions) { - const {projects = {}} = assertions; + /** + * assertions object structure: + * { + * projects: { + * "projectName": { + * skippedTasks: ["task1", "task2"], + * }, + * // ... + * }, + * allProjects: ["projectName1", "projectName2"] + * } + * + * projects - for asserting all projects which are expected to be built + * allProjects - optional, for asserting all seen projects nonetheless if built or not + */ + const {projects = {}, allProjects = []} = assertions; const projectsInOrder = []; const seenProjects = new Set(); @@ -2525,10 +2560,18 @@ class FixtureTester { } } - // Assert projects built in order + // Assert built projects in order const expectedProjects = Object.keys(projects); this._t.deepEqual(projectsInOrder, expectedProjects); + // Optional check: Assert seen projects + if (allProjects.length > 0) { + const expectedAllProjects = allProjects.sort(); + const actualAllProjects = Array.from(seenProjects).sort(); + this._t.deepEqual(actualAllProjects, expectedAllProjects, + "All seen projects (built or not) should match expected"); + } + // Assert skipped tasks per project for (const [projectName, expectedSkipped] of Object.entries(projects)) { const skippedTasks = expectedSkipped.skippedTasks || []; From ed0dac77f13836e229b39c48f24ed2aeda54ad0c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 19 Mar 2026 15:45:38 +0100 Subject: [PATCH 185/223] refactor(project): Validate source files after build finishes --- .../lib/build/cache/ProjectBuildCache.js | 67 ++++++++++- .../lib/build/ProjectBuilder.integration.js | 104 +++++------------- 2 files changed, 96 insertions(+), 75 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index c667d7a9d48..3def1c547c5 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -8,7 +8,7 @@ const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; import StageCache from "./StageCache.js"; import ResourceIndex from "./index/ResourceIndex.js"; -import {firstTruthy} from "./utils.js"; +import {firstTruthy, matchResourceMetadataStrict} from "./utils.js"; const log = getLogger("build:cache:ProjectBuildCache"); export const INDEX_STATES = Object.freeze({ @@ -771,6 +771,61 @@ export default class ProjectBuildCache { // TODO: Rename function? We simply use it to have a point in time right before the project is built } + /** + * Re-reads all source files from disk and compares them against the source index + * to detect whether any source files were modified, added, or deleted during the build. + * + * Uses metadata-only comparison via matchResourceMetadataStrict (skipping tags, + * since tags are build artifacts that always differ from fresh disk reads). + * + * @returns {Promise} True if source changes were detected during the build + */ + async #revalidateSourceIndex() { + const sourceReader = this.#project.getSourceReader(); + const currentResources = await sourceReader.byGlob("/**/*"); + + const tree = this.#sourceIndex.getTree(); + const indexedPaths = new Set(this.#sourceIndex.getResourcePaths()); + const currentPaths = new Set(); + + for (const resource of currentResources) { + const resourcePath = resource.getPath(); + currentPaths.add(resourcePath); + + const node = tree.getResourceByPath(resourcePath); + if (!node) { + // File was added during the build + log.verbose(`Source file added during build: ${resourcePath}`); + return true; + } + + const cachedMetadata = { + integrity: node.integrity, + lastModified: node.lastModified, + size: node.size, + inode: node.inode, + }; + const isUnchanged = await matchResourceMetadataStrict( + resource, cachedMetadata, tree.getIndexTimestamp() + ); + if (!isUnchanged) { + // File was modified during the build + log.verbose(`Source file modified during build: ${resourcePath}`); + return true; + } + } + + // Check for removed files + for (const indexedPath of indexedPaths) { + if (!currentPaths.has(indexedPath)) { + log.verbose(`Source file removed during build: ${indexedPath}`); + return true; + } + } + + return false; + } + /** * Signals that all tasks have completed and switches to the result stage * @@ -780,9 +835,19 @@ export default class ProjectBuildCache { * * @public * @returns {Promise} Array of changed resource paths since the last build + * @throws {Error} If source files were modified during the build */ async allTasksCompleted() { this.#project.getProjectResources().useResultStage(); + + const sourceChangedDuringBuild = await this.#revalidateSourceIndex(); + if (sourceChangedDuringBuild) { + throw new Error( + `Detected changes to source files of project ${this.#project.getName()} during the build. ` + + `The build result may be inconsistent and will not be used. ` + + `Build cache has not been updated.`); + } + if (this.#combinedIndexState === INDEX_STATES.INITIAL) { this.#combinedIndexState = INDEX_STATES.FRESH; } diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 073c145ff50..f7e813663f9 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2269,123 +2269,79 @@ test.serial("Build race condition: file modified during active build", async (t) const addedFileName = "added-during-build.js"; const addedFilePath = `${fixtureTester.fixturePath}/webapp/${addedFileName}`; - // #1 Build with race condition triggered by custom task - // The custom task (configured in ui5-race-condition.yaml) modifies test.js during the build, - // after the source index is created but before tasks that process test.js execute. - // This creates a race condition where the cached content hash no longer matches the actual file. - // - // Expected behavior: - // - Build should detect that source file hash changed during execution - // - Build should fail with an error OR mark cache as invalid - // - // FIXME: Current behavior: - // - Build succeeds without detecting the race condition - // - Cache is written with inconsistent data (index hash != processed content hash) - await fixtureTester.buildProject({ + // #1 Build with race condition triggered by custom task that modifies test.js during the build. + // The build should detect the source change and throw. + const error1 = await t.throwsAsync(fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-race-condition.yaml"}, config: {destPath, cleanDest: true}, - assertions: { - projects: { - "application.a": {} - } - } - }); - - // Verify the race condition occurred: the modification made by the custom task is in the output - const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); - t.true( - builtFileContent.includes(`RACE CONDITION MODIFICATION`), - "Build output contains the modification made during build" - ); + })); + t.true(error1.message.includes("Detected changes to source files of project application.a during the build"), + "Error message indicates source change detected"); // #2 Revert the source file to original content await fs.writeFile(testFilePath, originalContent); - // #3 Build again after reverting the source - // FIXME: The cache should be invalidated because the previous build had a race condition, - // but currently it's reused (projects: {}). Once proper validation is implemented, - // this should trigger a full rebuild: {"application.a": {}} + // #3 Build again with normal config after reverting the source. + // Since the race condition build threw, no corrupted cache was written. + // This build should succeed and produce clean output. await fixtureTester.buildProject({ - graphConfig: {rootConfigPath: "ui5-race-condition.yaml"}, config: {destPath, cleanDest: true}, assertions: { - projects: {} // Current: cache reused | Expected: {"application.a": {}} + projects: { + "application.a": {} + } } }); - // FIXME: Due to incorrect cache reuse from build #1, the output still contains the modification - // even though the source was reverted. This demonstrates the cache corruption issue. - // Expected: finalBuiltContent should NOT contain "RACE CONDITION MODIFICATION" + // Verify the output does NOT contain the race condition modification const finalBuiltContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); - t.true( + t.false( finalBuiltContent.includes(`RACE CONDITION MODIFICATION`), - "Build output incorrectly contains the modification due to corrupted cache" + "Build output does not contain race condition modification after clean rebuild" ); // #4 Build with race condition triggered by add-file custom task await fs.rm(addedFilePath, {force: true}); - await fixtureTester.buildProject({ + const error2 = await t.throwsAsync(fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-race-condition-add-file.yaml"}, config: {destPath, cleanDest: true}, - assertions: { - projects: { - "application.a": {} - } - } - }); - - const builtAddedFileContent = await fs.readFile(`${destPath}/${addedFileName}`, {encoding: "utf8"}); - t.true( - builtAddedFileContent.includes(`RACE CONDITION ADDED FILE`), - "Build output contains file added during active build" - ); + })); + t.true(error2.message.includes("Detected changes to source files of project application.a during the build"), + "Error message indicates source change detected (add file)"); // #5 Revert source state by removing the file that was added during build await fs.rm(addedFilePath, {force: true}); - // #6 Build again after removing the source file - // FIXME: The added file should trigger cache invalidation, but currently cache is reused. + // #6 Build again with normal config after reverting. + // Cache from build #3 is still valid (same source state), so everything should be skipped. await fixtureTester.buildProject({ - graphConfig: {rootConfigPath: "ui5-race-condition-add-file.yaml"}, config: {destPath, cleanDest: true}, assertions: { - projects: {} // Current: cache reused | Expected: {"application.a": {}} + projects: {} } }); - const staleAddedFileContent = await fs.readFile(`${destPath}/${addedFileName}`, {encoding: "utf8"}); - t.true( - staleAddedFileContent.includes(`RACE CONDITION ADDED FILE`), - "Build output incorrectly keeps added file due to corrupted cache" - ); - // #7 Build with race condition triggered by delete-file custom task - await fixtureTester.buildProject({ + const error3 = await t.throwsAsync(fixtureTester.buildProject({ graphConfig: {rootConfigPath: "ui5-race-condition-delete-file.yaml"}, config: {destPath, cleanDest: true}, - assertions: { - projects: { - "application.a": {} - } - } - }); - - // File was deleted during build and therefore not part of the output - await t.throwsAsync(fs.readFile(`${destPath}/test.js`, {encoding: "utf8"})); + })); + t.true(error3.message.includes("Detected changes to source files of project application.a during the build"), + "Error message indicates source change detected (delete file)"); // #8 Revert source state by restoring the deleted file await fs.writeFile(testFilePath, originalContent); - // #9 Build again after restoring the source file - // FIXME: The restored file should trigger cache invalidation, but currently cache is reused. + // #9 Build again with normal config after restoring. + // Cache from build #3 is still valid (same source state), so everything should be skipped. await fixtureTester.buildProject({ - graphConfig: {rootConfigPath: "ui5-race-condition-delete-file.yaml"}, config: {destPath, cleanDest: true}, assertions: { - projects: {} // Current: cache reused | Expected: {"application.a": {}} + projects: {} } }); + // Verify test.js is present in output const restoredBuiltFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); t.true( restoredBuiltFileContent.includes(`console.log`), From cc7982b3f972257c0ca64af93e5f8af254f96f20 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 19 Mar 2026 17:29:04 +0100 Subject: [PATCH 186/223] refactor(project): Store source files in CAS --- .../lib/build/cache/ProjectBuildCache.js | 98 ++++- .../test/lib/build/cache/ProjectBuildCache.js | 349 +++++++++++++++++- 2 files changed, 445 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 3def1c547c5..1acc526996c 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -280,10 +280,13 @@ export default class ProjectBuildCache { } const [resultSignature, resultMetadata] = res; log.verbose(`Found result cache with signature ${resultSignature}`); - const {stageSignatures} = resultMetadata; + const {stageSignatures, sourceStageSignature} = resultMetadata; const writtenResourcePaths = await this.#importStages(stageSignatures); + // Restore CAS-backed source reader from the stored source stage + await this.#restoreFrozenSources(sourceStageSignature); + log.verbose( `Using cached result stage for project ${this.#project.getName()} with index signature ${resultSignature}`); this.#currentResultSignature = resultSignature; @@ -826,6 +829,95 @@ export default class ProjectBuildCache { return false; } + /** + * Write untransformed source files (not overlayed by any build task) to the CAS + * and persist their metadata in the stage cache. + * + * This enables downstream projects to read dependency source files from the CAS + * snapshot instead of the live filesystem, preventing race conditions from source + * changes between project builds. + * + * In subsequent builds where the source index signature hasn't changed, the stored + * metadata can be used to recreate a CAS-backed reader without rebuilding the dependency. + */ + async #freezeUntransformedSources() { + const transformedPaths = new Set(this.#writtenResultResourcePaths); + const untransformedPaths = this.#sourceIndex.getResourcePaths() + .filter((p) => !transformedPaths.has(p)); + + if (untransformedPaths.length === 0) { + log.verbose( + `All source files of project ${this.#project.getName()} are overlayed by build tasks`); + return; + } + + const sourceReader = this.#project.getSourceReader(); + const sourceSignature = this.#sourceIndex.getSignature(); + + // Read untransformed source files + const resources = await Promise.all(untransformedPaths.map(async (resourcePath) => { + const resource = await sourceReader.byPath(resourcePath); + if (!resource) { + throw new Error( + `Source file ${resourcePath} not found during CAS freeze ` + + `for project ${this.#project.getName()}`); + } + return resource; + })); + + // Write resources to CAS and collect metadata (reuses existing helper) + const resourceMetadata = await this.#writeStageResources(resources, "source", sourceSignature); + + // Persist source stage metadata in the stage cache + await this.#cacheManager.writeStageCache( + this.#project.getId(), this.#buildSignature, "source", sourceSignature, + {resourceMetadata}); + + log.verbose( + `Stored ${untransformedPaths.length} untransformed source files of project ` + + `${this.#project.getName()} in CAS with signature ${sourceSignature}`); + + // Create CAS-backed proxy reader for the untransformed source files + const casSourceReader = this.#createReaderForStageCache("source", sourceSignature, resourceMetadata); + + // TODO: Replace the project's filesystem-backed source reader with the CAS-backed reader + // via ProjectResources.setSourceReader(casSourceReader) to protect downstream consumers + // from filesystem race conditions. + void casSourceReader; + } + + /** + * Restores the CAS-backed reader for untransformed source files from a previous build's + * cached stage metadata. + * + * @param {string} sourceStageSignature The source index signature used when the source + * stage was persisted + */ + async #restoreFrozenSources(sourceStageSignature) { + const stageMetadata = await this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, "source", sourceStageSignature); + + if (!stageMetadata) { + log.verbose( + `No cached source stage found for project ${this.#project.getName()} ` + + `with signature ${sourceStageSignature}`); + return; + } + + const {resourceMetadata} = stageMetadata; + log.verbose( + `Restored ${Object.keys(resourceMetadata).length} frozen source files for project ` + + `${this.#project.getName()} from CAS`); + + const casSourceReader = this.#createReaderForStageCache( + "source", sourceStageSignature, resourceMetadata); + + // TODO: Replace the project's filesystem-backed source reader with the CAS-backed reader + // via ProjectResources.setSourceReader(casSourceReader) to protect downstream consumers + // from filesystem race conditions. + void casSourceReader; + } + /** * Signals that all tasks have completed and switches to the result stage * @@ -848,6 +940,9 @@ export default class ProjectBuildCache { `Build cache has not been updated.`); } + // Write untransformed source files to CAS for downstream consumer protection + await this.#freezeUntransformedSources(); + if (this.#combinedIndexState === INDEX_STATES.INITIAL) { this.#combinedIndexState = INDEX_STATES.FRESH; } @@ -1030,6 +1125,7 @@ export default class ProjectBuildCache { const metadata = { stageSignatures, + sourceStageSignature: this.#sourceIndex.getSignature(), }; await this.#cacheManager.writeResultMetadata( this.#project.getId(), this.#buildSignature, stageSignature, metadata); diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 933625b8578..badfbd6b74c 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -63,7 +63,8 @@ function createMockCacheManager() { writeResultMetadata: sinon.stub().resolves(), readTaskMetadata: sinon.stub().resolves(null), writeTaskMetadata: sinon.stub().resolves(), - writeStageResource: sinon.stub().resolves() + writeStageResource: sinon.stub().resolves(), + getResourcePathForStage: sinon.stub().resolves("/fake/cache/path") }; } @@ -607,3 +608,349 @@ test("Empty task list doesn't fail", async (t) => { t.true(project.getProjectResources().initStages.calledWith([]), "initStages called with empty array"); }); + +// ===== CAS SOURCE FREEZE TESTS ===== + +// Helper: Creates a ProjectBuildCache with a populated source index containing the given resources. +// Runs a single task that writes `writtenPaths` and then calls allTasksCompleted. +// Returns {cache, project, cacheManager} for assertions. +async function buildCacheWithTaskResult(resources, writtenPaths = []) { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-sig"; + + // Source reader returns the given resources for byGlob and individual byPath + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves(resources), + byPath: sinon.stub().callsFake((path) => { + const res = resources.find((r) => r.getPath() === path); + return Promise.resolve(res || null); + }) + })); + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + + // Set up and execute a task + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + // Simulate task writing some resources + const writtenResources = writtenPaths.map( + (p) => createMockResource(p, `hash-${p}`, 2000, 200, 2) + ); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves(writtenResources) + }) + }); + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, null, false); + + return {cache, project, cacheManager}; +} + +test("freezeUntransformedSources: writes only untransformed source files to CAS", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + const resC = createMockResource("/c.js", "hash-c", 1000, 100, 3); + const resD = createMockResource("/d.js", "hash-d", 1000, 100, 4); + + // Task writes /a.js and /b.js, so /c.js and /d.js are untransformed + const {cache, cacheManager} = await buildCacheWithTaskResult( + [resA, resB, resC, resD], + ["/a.js", "/b.js"] + ); + + await cache.allTasksCompleted(); + + // writeStageResource should be called for untransformed files /c.js and /d.js + const stageResourceCalls = cacheManager.writeStageResource.getCalls(); + const writtenPaths = stageResourceCalls.map((call) => call.args[3].getOriginalPath()); + t.true(writtenPaths.includes("/c.js"), "Untransformed /c.js written to CAS"); + t.true(writtenPaths.includes("/d.js"), "Untransformed /d.js written to CAS"); + t.false(writtenPaths.includes("/a.js"), "Transformed /a.js NOT written to CAS by freeze"); + t.false(writtenPaths.includes("/b.js"), "Transformed /b.js NOT written to CAS by freeze"); +}); + +test("freezeUntransformedSources: early return when all sources overlayed", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + + // Task writes all source files + const {cache, cacheManager} = await buildCacheWithTaskResult( + [resA, resB], + ["/a.js", "/b.js"] + ); + + await cache.allTasksCompleted(); + + // writeStageCache should NOT be called with stageId "source" + const sourceStageCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + t.is(sourceStageCalls.length, 0, "No source stage cache written when all files overlayed"); +}); + +test("freezeUntransformedSources: writes stage cache with correct stageId and signature", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + + // Task writes only /a.js, so /b.js is untransformed + const {cache, cacheManager} = await buildCacheWithTaskResult( + [resA, resB], + ["/a.js"] + ); + + await cache.allTasksCompleted(); + + // writeStageCache called with stageId "source" + const sourceStageCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + t.is(sourceStageCalls.length, 1, "writeStageCache called once for source stage"); + + const call = sourceStageCalls[0]; + t.is(call.args[0], "test-project-id", "Correct project ID"); + t.is(call.args[1], "test-sig", "Correct build signature"); + t.is(call.args[2], "source", "Correct stageId"); + t.is(typeof call.args[3], "string", "Signature is a string"); + t.truthy(call.args[4].resourceMetadata, "Metadata contains resourceMetadata"); + t.truthy(call.args[4].resourceMetadata["/b.js"], "resourceMetadata has entry for untransformed /b.js"); + t.falsy(call.args[4].resourceMetadata["/a.js"], "resourceMetadata does NOT have entry for transformed /a.js"); +}); + +test("freezeUntransformedSources: throws when source file not found", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + // First call during init returns both resources; second call during freeze returns only /a.js + let callCount = 0; + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().callsFake(() => { + callCount++; + if (callCount <= 1) { + return Promise.resolve([resA, resB]); + } + return Promise.resolve([resA, resB]); + }), + byPath: sinon.stub().callsFake((path) => { + // During freeze, /b.js disappears + if (path === "/b.js") { + return Promise.resolve(null); + } + return Promise.resolve(resA); + }) + })); + + const cache = await ProjectBuildCache.create(project, "test-sig", cacheManager); + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([createMockResource("/a.js", "hash-a", 2000, 200, 2)]) + }) + }); + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, null, false); + + const error = await t.throwsAsync(() => cache.allTasksCompleted()); + t.true(error.message.includes("not found during CAS freeze"), + "Error message mentions CAS freeze"); +}); + +// ===== RESULT METADATA SHAPE TESTS ===== + +test("writeResultCache: metadata includes sourceStageSignature", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + + const {cache, cacheManager} = await buildCacheWithTaskResult( + [resA], + ["/a.js"] + ); + + await cache.allTasksCompleted(); + await cache.writeCache(); + + // writeResultMetadata should have been called + t.true(cacheManager.writeResultMetadata.called, "writeResultMetadata was called"); + + const metadataCall = cacheManager.writeResultMetadata.getCall(0); + const metadata = metadataCall.args[3]; + t.truthy(metadata.stageSignatures, "Metadata contains stageSignatures"); + t.is(typeof metadata.sourceStageSignature, "string", + "Metadata contains sourceStageSignature as string"); +}); + +// ===== RESTORE FROZEN SOURCES TESTS ===== + +test("restoreFrozenSources: cache miss logs verbose and continues", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resA]), + byPath: sinon.stub().resolves(resA) + })); + + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash-a", + children: { + "a.js": { + hash: "hash-a", + metadata: { + path: "/a.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [["task1", false]] + }; + cacheManager.readIndexCache.resolves(indexCache); + cacheManager.readTaskMetadata.callsFake((projectId, buildSig, taskName, type) => { + return Promise.resolve({ + requestSetGraph: {nodes: [], nextId: 1}, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: true + }); + }); + + // readResultMetadata returns metadata WITH sourceStageSignature + cacheManager.readResultMetadata.resolves({ + stageSignatures: {"task/task1": "sig1-sig2"}, + sourceStageSignature: "source-sig-123" + }); + + // readStageCache for task stage returns valid data, but for "source" stage returns null + cacheManager.readStageCache.callsFake((projectId, buildSig, stageName, signature) => { + if (stageName === "source") { + return Promise.resolve(null); // Cache miss for source stage + } + // Return valid stage for task stages + return Promise.resolve({ + resourceMetadata: {"/a.js": {integrity: "hash-a", lastModified: 1000, size: 100, inode: 1}}, + projectTagOperations: {}, + buildTagOperations: {}, + }); + }); + + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const mockDepReader = { + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }; + const result = await cache.prepareProjectBuildAndValidateCache(mockDepReader); + + // Should succeed without error — the cache miss for source stage is non-fatal + t.truthy(result, "prepareProjectBuildAndValidateCache succeeds despite source cache miss"); + + // Verify readStageCache was called with "source" stageId + const sourceReadCalls = cacheManager.readStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + t.true(sourceReadCalls.length > 0, "readStageCache was called for source stage"); +}); + +test("restoreFrozenSources: cache hit creates CAS reader", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves([resA]), + byPath: sinon.stub().resolves(resA) + })); + + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 1000, + root: { + hash: "hash-a", + children: { + "a.js": { + hash: "hash-a", + metadata: { + path: "/a.js", + lastModified: 1000, + size: 100, + inode: 1 + } + } + } + } + }, + tasks: [["task1", false]] + }; + cacheManager.readIndexCache.resolves(indexCache); + cacheManager.readTaskMetadata.callsFake((projectId, buildSig, taskName, type) => { + return Promise.resolve({ + requestSetGraph: {nodes: [], nextId: 1}, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: true + }); + }); + + // readResultMetadata returns metadata WITH sourceStageSignature + cacheManager.readResultMetadata.resolves({ + stageSignatures: {"task/task1": "sig1-sig2"}, + sourceStageSignature: "source-sig-456" + }); + + // readStageCache returns valid data for both task and source stages + cacheManager.readStageCache.callsFake((projectId, buildSig, stageName, signature) => { + if (stageName === "source") { + return Promise.resolve({ + resourceMetadata: { + "/b.js": {integrity: "hash-b", lastModified: 1000, size: 100, inode: 2} + }, + }); + } + return Promise.resolve({ + resourceMetadata: {"/a.js": {integrity: "hash-a", lastModified: 1000, size: 100, inode: 1}}, + projectTagOperations: {}, + buildTagOperations: {}, + }); + }); + + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + + const mockDepReader = { + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }; + const result = await cache.prepareProjectBuildAndValidateCache(mockDepReader); + + t.truthy(result, "Cache restored successfully"); + + // Verify readStageCache was called with "source" stageId and the correct signature + const sourceReadCalls = cacheManager.readStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + t.true(sourceReadCalls.length > 0, "readStageCache was called for source stage"); + t.is(sourceReadCalls[0].args[3], "source-sig-456", + "readStageCache called with correct source signature"); +}); From bdfce5d4c759038df6f668b3b662a833707cb348 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 19 Mar 2026 18:33:55 +0100 Subject: [PATCH 187/223] refactor(project): Use stored source files in ProjectResources --- .../lib/build/cache/ProjectBuildCache.js | 17 +-- .../project/lib/resources/ProjectResources.js | 24 ++++ .../test/lib/build/cache/ProjectBuildCache.js | 25 +++- .../test/lib/resources/ProjectResources.js | 110 ++++++++++++++++++ 4 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 packages/project/test/lib/resources/ProjectResources.js diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 1acc526996c..9cd7b36fb4b 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -880,10 +880,7 @@ export default class ProjectBuildCache { // Create CAS-backed proxy reader for the untransformed source files const casSourceReader = this.#createReaderForStageCache("source", sourceSignature, resourceMetadata); - // TODO: Replace the project's filesystem-backed source reader with the CAS-backed reader - // via ProjectResources.setSourceReader(casSourceReader) to protect downstream consumers - // from filesystem race conditions. - void casSourceReader; + this.#project.getProjectResources().setFrozenSourceReader(casSourceReader); } /** @@ -899,23 +896,19 @@ export default class ProjectBuildCache { if (!stageMetadata) { log.verbose( - `No cached source stage found for project ${this.#project.getName()} ` + - `with signature ${sourceStageSignature}`); + `No cached source stage metadata found for project ${this.#project.getName()} ` + + `with signature ${sourceStageSignature}. Skipping frozen source restore.`); return; } const {resourceMetadata} = stageMetadata; log.verbose( - `Restored ${Object.keys(resourceMetadata).length} frozen source files for project ` + - `${this.#project.getName()} from CAS`); + `Restored frozen source files for project ${this.#project.getName()} from CAS`); const casSourceReader = this.#createReaderForStageCache( "source", sourceStageSignature, resourceMetadata); - // TODO: Replace the project's filesystem-backed source reader with the CAS-backed reader - // via ProjectResources.setSourceReader(casSourceReader) to protect downstream consumers - // from filesystem race conditions. - void casSourceReader; + this.#project.getProjectResources().setFrozenSourceReader(casSourceReader); } /** diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index 365576d2541..790bf23b250 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -26,6 +26,9 @@ class ProjectResources { #currentStageWorkspace; #currentStageReaders; // Map to store the various reader styles + // CAS-backed frozen source reader (set after build or restore from cache) + #frozenSourceReader = null; + // Callbacks (interface object) #getName; #getStyledReader; @@ -137,6 +140,11 @@ class ProjectResources { this.#addReaderForStage(this.#stages[i], readers, style); } + // Add CAS-backed frozen source reader (if available) + if (this.#frozenSourceReader) { + readers.push(this.#frozenSourceReader); + } + // Finally add the project's source reader readers.push(this.#getStyledReader(style)); @@ -154,6 +162,21 @@ class ProjectResources { return this.#getStyledReader(style); } + /** + * Sets a CAS-backed frozen source reader that provides immutable snapshots + * of untransformed source files. This reader is inserted into the reader chain + * between stage readers and the filesystem source reader, so that downstream + * dependency consumers read from CAS instead of the live filesystem. + * + * @public + * @param {@ui5/fs/AbstractReader} reader CAS-backed reader for frozen source files + */ + setFrozenSourceReader(reader) { + this.#frozenSourceReader = reader; + // Invalidate cached readers since the reader chain changed + this.#currentStageReaders = new Map(); + } + /** * Get a [DuplexCollection]{@link @ui5/fs/DuplexCollection} for accessing and modifying a * project's resources. This is always of style buildtime. @@ -218,6 +241,7 @@ class ProjectResources { this.#lastTagCacheImportIndex = -1; this.#currentStageReaders = new Map(); this.#currentStageWorkspace = null; + this.#frozenSourceReader = null; this.#projectResourceTagCollection = null; } diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index badfbd6b74c..8a210cfac40 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -39,6 +39,7 @@ function createMockProject(name = "test.project", id = "test-project-id") { buildTagOperations: new Map(), }), buildFinished: sinon.stub(), + setFrozenSourceReader: sinon.stub(), }; return { @@ -699,7 +700,7 @@ test("freezeUntransformedSources: writes stage cache with correct stageId and si const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); // Task writes only /a.js, so /b.js is untransformed - const {cache, cacheManager} = await buildCacheWithTaskResult( + const {cache, project, cacheManager} = await buildCacheWithTaskResult( [resA, resB], ["/a.js"] ); @@ -720,6 +721,13 @@ test("freezeUntransformedSources: writes stage cache with correct stageId and si t.truthy(call.args[4].resourceMetadata, "Metadata contains resourceMetadata"); t.truthy(call.args[4].resourceMetadata["/b.js"], "resourceMetadata has entry for untransformed /b.js"); t.falsy(call.args[4].resourceMetadata["/a.js"], "resourceMetadata does NOT have entry for transformed /a.js"); + + // Verify setFrozenSourceReader was called on project resources + const projectResources = project.getProjectResources(); + t.true(projectResources.setFrozenSourceReader.calledOnce, + "setFrozenSourceReader called once after freeze"); + t.truthy(projectResources.setFrozenSourceReader.firstCall.args[0], + "setFrozenSourceReader called with a reader"); }); test("freezeUntransformedSources: throws when source file not found", async (t) => { @@ -793,7 +801,7 @@ test("writeResultCache: metadata includes sourceStageSignature", async (t) => { // ===== RESTORE FROZEN SOURCES TESTS ===== -test("restoreFrozenSources: cache miss logs verbose and continues", async (t) => { +test("restoreFrozenSources: cache miss skips gracefully", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); @@ -862,9 +870,13 @@ test("restoreFrozenSources: cache miss logs verbose and continues", async (t) => }; const result = await cache.prepareProjectBuildAndValidateCache(mockDepReader); - // Should succeed without error — the cache miss for source stage is non-fatal + // Should succeed without error — cache miss for source stage is non-fatal t.truthy(result, "prepareProjectBuildAndValidateCache succeeds despite source cache miss"); + // setFrozenSourceReader should NOT have been called + t.false(project.getProjectResources().setFrozenSourceReader.called, + "setFrozenSourceReader not called on cache miss"); + // Verify readStageCache was called with "source" stageId const sourceReadCalls = cacheManager.readStageCache.getCalls().filter( (call) => call.args[2] === "source" @@ -953,4 +965,11 @@ test("restoreFrozenSources: cache hit creates CAS reader", async (t) => { t.true(sourceReadCalls.length > 0, "readStageCache was called for source stage"); t.is(sourceReadCalls[0].args[3], "source-sig-456", "readStageCache called with correct source signature"); + + // Verify setFrozenSourceReader was called on project resources after restore + const projectResources = project.getProjectResources(); + t.true(projectResources.setFrozenSourceReader.calledOnce, + "setFrozenSourceReader called once after restore"); + t.truthy(projectResources.setFrozenSourceReader.firstCall.args[0], + "setFrozenSourceReader called with a reader"); }); diff --git a/packages/project/test/lib/resources/ProjectResources.js b/packages/project/test/lib/resources/ProjectResources.js new file mode 100644 index 00000000000..0b5b874412c --- /dev/null +++ b/packages/project/test/lib/resources/ProjectResources.js @@ -0,0 +1,110 @@ +import test from "ava"; +import sinon from "sinon"; +import ProjectResources from "../../../lib/resources/ProjectResources.js"; + +function createProjectResources({frozenSourceReader} = {}) { + const sourceReader = { + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null), + }; + const writer = { + byGlob: sinon.stub().resolves([]), + write: sinon.stub().resolves(), + }; + const pr = new ProjectResources({ + getName: () => "test.project", + getStyledReader: sinon.stub().returns(sourceReader), + createWriter: sinon.stub().returns(writer), + addReadersForWriter: sinon.stub(), + buildManifest: null, + }); + if (frozenSourceReader) { + pr.setFrozenSourceReader(frozenSourceReader); + } + return {pr, sourceReader, writer}; +} + +test.afterEach.always(() => { + sinon.restore(); +}); + +test("setFrozenSourceReader: frozen reader is included in getReader chain", (t) => { + const frozenReader = {name: "frozen-cas-reader"}; + const {pr} = createProjectResources({frozenSourceReader: frozenReader}); + + // getReader returns a prioritized collection; we can't easily inspect internals, + // but we verify it returns a reader without errors and that the frozen reader was set. + const reader = pr.getReader(); + t.truthy(reader, "Reader returned successfully"); +}); + +test("setFrozenSourceReader: invalidates cached readers", (t) => { + const {pr} = createProjectResources(); + + // Access the reader to populate the cache + const reader1 = pr.getReader(); + + // Set a frozen source reader — this should invalidate the cache + const frozenReader = {name: "frozen-cas-reader"}; + pr.setFrozenSourceReader(frozenReader); + + // Getting the reader again should produce a new instance (not the cached one) + const reader2 = pr.getReader(); + t.not(reader1, reader2, "Cached reader was invalidated; new reader instance returned"); +}); + +test("setFrozenSourceReader: frozen reader is between stages and source reader", (t) => { + const frozenReader = {name: "frozen-cas-reader"}; + const sourceReader = { + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null), + }; + const writer = { + byGlob: sinon.stub().resolves([]), + write: sinon.stub().resolves(), + }; + const getStyledReader = sinon.stub().returns(sourceReader); + const addReadersForWriter = sinon.stub(); + const pr = new ProjectResources({ + getName: () => "test.project", + getStyledReader, + createWriter: sinon.stub().returns(writer), + addReadersForWriter, + buildManifest: null, + }); + pr.setFrozenSourceReader(frozenReader); + + // Initialize stages and switch to one to exercise #addReaderForStage + pr.initStages(["stage1"]); + pr.useStage("stage1"); + + // Calling getWorkspace triggers #getReaders (buildtime style) for the workspace reader + const workspace = pr.getWorkspace(); + t.truthy(workspace, "Workspace created successfully with frozen reader in chain"); +}); + +test("getReader without frozen source reader works normally", (t) => { + const {pr} = createProjectResources(); + + const reader = pr.getReader(); + t.truthy(reader, "Reader returned without frozen source reader"); +}); + +test("initStages clears frozen source reader", (t) => { + const frozenReader = {name: "frozen-cas-reader"}; + const {pr} = createProjectResources({frozenSourceReader: frozenReader}); + + // Verify frozen reader is active + const reader1 = pr.getReader(); + t.truthy(reader1, "Reader with frozen source reader"); + + // initStages resets all stage state including the frozen reader + pr.initStages(["stage1"]); + + // After initStages, a new reader should be created without the frozen reader + // (cache was invalidated). We can't directly inspect the chain, but we verify + // that it doesn't throw and returns a fresh reader. + const reader2 = pr.getReader(); + t.truthy(reader2, "Reader returned after initStages"); + t.not(reader1, reader2, "Reader was recreated after initStages"); +}); From f57ca5c026cbf38ac1d5a804660da6e1f62ec9ba Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 19 Mar 2026 18:47:56 +0100 Subject: [PATCH 188/223] test(project): Add tests for cross project source file modifications --- .../lib/build/cache/index/TreeRegistry.js | 3 +- .../dependency-race-condition-task.js | 61 +++++++++++++++++++ .../library.d/main/src/library/d/data.json | 1 + .../ui5-dependency-race-condition.yaml | 17 ++++++ .../library.d/main/src/library/d/data.json | 1 + .../lib/build/ProjectBuilder.integration.js | 33 ++++++++++ .../test/lib/resources/ProjectResources.js | 55 +++++++++++++++++ 7 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/project/test/fixtures/application.a/dependency-race-condition-task.js create mode 100644 packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/data.json create mode 100644 packages/project/test/fixtures/application.a/ui5-dependency-race-condition.yaml create mode 100644 packages/project/test/fixtures/library.d/main/src/library/d/data.json diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 5b69fb11f36..79fd35e99e4 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -349,7 +349,8 @@ export default class TreeRegistry { resourceNode.lastModified = upsert.resource.getLastModified(); resourceNode.size = await upsert.resource.getSize(); resourceNode.inode = upsert.resource.getInode(); - resourceNode.tags = upsert.resource.getTags?.() ?? upsert.resource.tags ?? resourceNode.tags; + resourceNode.tags = upsert.resource.getTags?.() ?? + upsert.resource.tags ?? resourceNode.tags; modifiedNodes.add(resourceNode); dirModified = true; diff --git a/packages/project/test/fixtures/application.a/dependency-race-condition-task.js b/packages/project/test/fixtures/application.a/dependency-race-condition-task.js new file mode 100644 index 00000000000..9fac2ae248d --- /dev/null +++ b/packages/project/test/fixtures/application.a/dependency-race-condition-task.js @@ -0,0 +1,61 @@ +const {readFile, writeFile} = require("fs/promises"); +const path = require("path"); + +/** + * Custom task that verifies the frozen CAS reader protects against + * cross-project dependency source race conditions. + * + * Uses data.json — an untransformed source file (no placeholders, not processed by + * minify/replaceCopyright/replaceVersion). This is critical because transformed files + * are written to stage writers which have higher priority than both the frozen reader + * and the filesystem reader, making disk modifications invisible regardless. + * + * Flow: + * 1. Read library.d's data.json via the dependency reader (CAS-backed) + * 2. Overwrite the file on disk with different content + * 3. Re-read via the dependency reader + * 4. Assert the content is still the original (CAS-served) + * 5. Restore the original file on disk + */ +module.exports = async function ({taskUtil}) { + const libDProject = taskUtil.getProject("library.d"); + const libDReader = libDProject.getReader(); + + // Step 1: Read the original content via the dependency reader (CAS-backed) + // data.json is untransformed (no placeholders, not a .js file subject to minification) + // so it is only served by the frozen CAS reader or the filesystem reader + const resourcePath = "/resources/library/d/data.json"; + const originalResource = await libDReader.byPath(resourcePath); + if (!originalResource) { + throw new Error(`Resource ${resourcePath} not found via dependency reader`); + } + const originalContent = await originalResource.getString(); + + // Step 2: Overwrite the file on disk + const sourcePath = libDProject.getSourcePath(); + const diskFilePath = path.join(sourcePath, "library", "d", "data.json"); + const diskOriginalContent = await readFile(diskFilePath, {encoding: "utf8"}); + await writeFile(diskFilePath, JSON.stringify({key: "modified-by-race-condition"})); + + try { + // Step 3: Re-read via the dependency reader — should still return CAS-frozen content + // Without the frozen reader, this would read the modified disk content + const reReadResource = await libDReader.byPath(resourcePath); + if (!reReadResource) { + throw new Error(`Resource ${resourcePath} not found on re-read via dependency reader`); + } + const reReadContent = await reReadResource.getString(); + + // Step 4: Assert the content is still the original (not modified disk content) + if (reReadContent !== originalContent) { + throw new Error( + "Frozen source reader protection failed: dependency reader returned modified disk content " + + "instead of the original CAS-frozen content. " + + `Expected: ${JSON.stringify(originalContent)}, Got: ${JSON.stringify(reReadContent)}` + ); + } + } finally { + // Step 5: Always restore the original file on disk + await writeFile(diskFilePath, diskOriginalContent); + } +}; diff --git a/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/data.json b/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/data.json new file mode 100644 index 00000000000..9658000784b --- /dev/null +++ b/packages/project/test/fixtures/application.a/node_modules/library.d/main/src/library/d/data.json @@ -0,0 +1 @@ +{"key": "original-value"} diff --git a/packages/project/test/fixtures/application.a/ui5-dependency-race-condition.yaml b/packages/project/test/fixtures/application.a/ui5-dependency-race-condition.yaml new file mode 100644 index 00000000000..02ed3896358 --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-dependency-race-condition.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: dependency-race-condition-task + afterTask: escapeNonAsciiCharacters +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: dependency-race-condition-task +task: + path: dependency-race-condition-task.js diff --git a/packages/project/test/fixtures/library.d/main/src/library/d/data.json b/packages/project/test/fixtures/library.d/main/src/library/d/data.json new file mode 100644 index 00000000000..9658000784b --- /dev/null +++ b/packages/project/test/fixtures/library.d/main/src/library/d/data.json @@ -0,0 +1 @@ +{"key": "original-value"} diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index f7e813663f9..8662471cefc 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2349,6 +2349,39 @@ test.serial("Build race condition: file modified during active build", async (t) ); }); +test.serial("Build dependency race condition: frozen source reader protects against filesystem changes", + async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // Build with dependency-race-condition custom task and all dependencies included. + // library.d is built first → its sources are frozen in CAS. + // Then application.a builds, running the custom task that: + // 1. Reads library.d's some.js via the dependency reader (CAS-backed) + // 2. Modifies some.js on disk + // 3. Re-reads via the dependency reader + // 4. Asserts the content is still the original CAS-frozen content (not modified disk) + // 5. Restores the file on disk + // If the frozen reader is not working, the custom task throws and the build fails. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-dependency-race-condition.yaml"}, + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // Sanity check: verify library.d's some.js exists in build output + const builtContent = await fs.readFile(`${destPath}/resources/library/d/some.js`, {encoding: "utf8"}); + t.truthy(builtContent, "library.d some.js exists in build output"); + }); + test.serial("Build with dependencies: Verify sap-ui-version.json generation and regeneration", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; diff --git a/packages/project/test/lib/resources/ProjectResources.js b/packages/project/test/lib/resources/ProjectResources.js index 0b5b874412c..69aa964531e 100644 --- a/packages/project/test/lib/resources/ProjectResources.js +++ b/packages/project/test/lib/resources/ProjectResources.js @@ -1,5 +1,6 @@ import test from "ava"; import sinon from "sinon"; +import {createProxy, createResource} from "@ui5/fs/resourceFactory"; import ProjectResources from "../../../lib/resources/ProjectResources.js"; function createProjectResources({frozenSourceReader} = {}) { @@ -108,3 +109,57 @@ test("initStages clears frozen source reader", (t) => { t.truthy(reader2, "Reader returned after initStages"); t.not(reader1, reader2, "Reader was recreated after initStages"); }); + +test("Frozen source reader takes priority over filesystem source reader", async (t) => { + const resourcePath = "/resources/test/some.js"; + const filesystemContent = "filesystem content"; + const frozenCASContent = "frozen CAS content"; + + // Create a source reader that simulates the filesystem + const filesystemResource = createResource({path: resourcePath, string: filesystemContent}); + const sourceReader = createProxy({ + name: "Filesystem source reader", + listResourcePaths: () => [resourcePath], + getResource: async (virPath) => { + if (virPath === resourcePath) { + return filesystemResource; + } + return null; + } + }); + + // Create a frozen CAS reader with different content + const frozenResource = createResource({path: resourcePath, string: frozenCASContent}); + const frozenReader = createProxy({ + name: "Frozen CAS reader", + listResourcePaths: () => [resourcePath], + getResource: async (virPath) => { + if (virPath === resourcePath) { + return frozenResource; + } + return null; + } + }); + + const writer = { + byGlob: sinon.stub().resolves([]), + write: sinon.stub().resolves(), + }; + + const pr = new ProjectResources({ + getName: () => "test.project", + getStyledReader: sinon.stub().returns(sourceReader), + createWriter: sinon.stub().returns(writer), + addReadersForWriter: sinon.stub(), + buildManifest: null, + }); + + pr.setFrozenSourceReader(frozenReader); + + const reader = pr.getReader(); + const result = await reader.byPath(resourcePath); + t.truthy(result, "Resource found via reader"); + const content = await result.getString(); + t.is(content, frozenCASContent, + "Frozen CAS reader takes priority over filesystem source reader"); +}); From ff08fb000718120e7c07538021f9e68a315d384b Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Fri, 27 Mar 2026 10:41:29 +0100 Subject: [PATCH 189/223] test(internal): Add new "e2e-tests" dir to internal/ * Add test for "ui5 --version" * Add test for "ui5 build" (currently testing a TS application) Currently, those tests are not included in any CI pipeline and also need a manual "npm install" (no node_modules included yet). --- .gitignore | 5 +- .../application.a.ts/package-lock.json | 2337 +++++++++++++++++ .../fixtures/application.a.ts/package.json | 10 + .../fixtures/application.a.ts/tsconfig.json | 21 + .../fixtures/application.a.ts/ui5.yaml | 12 + .../webapp/controller/Test.controller.ts | 22 + .../application.a.ts/webapp/manifest.json | 66 + internal/e2e-tests/package.json | 20 + internal/e2e-tests/test/build.js | 52 + internal/e2e-tests/test/version.js | 31 + 10 files changed, 2575 insertions(+), 1 deletion(-) create mode 100644 internal/e2e-tests/fixtures/application.a.ts/package-lock.json create mode 100644 internal/e2e-tests/fixtures/application.a.ts/package.json create mode 100644 internal/e2e-tests/fixtures/application.a.ts/tsconfig.json create mode 100644 internal/e2e-tests/fixtures/application.a.ts/ui5.yaml create mode 100644 internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts create mode 100644 internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json create mode 100644 internal/e2e-tests/package.json create mode 100644 internal/e2e-tests/test/build.js create mode 100644 internal/e2e-tests/test/version.js diff --git a/.gitignore b/.gitignore index 69f95b1b683..b5616c9274b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,7 @@ internal/documentation/.vitepress/cache internal/documentation/dist internal/documentation/schema/* internal/documentation/docs/api -internal/documentation/tmp \ No newline at end of file +internal/documentation/tmp + +# E2E tests +internal/e2e-tests/tmp \ No newline at end of file diff --git a/internal/e2e-tests/fixtures/application.a.ts/package-lock.json b/internal/e2e-tests/fixtures/application.a.ts/package-lock.json new file mode 100644 index 00000000000..d4a4e2640ed --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/package-lock.json @@ -0,0 +1,2337 @@ +{ + "name": "application.a.ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "application.a.ts", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@openui5/types": "1.115.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@openui5/types": { + "version": "1.115.1", + "resolved": "https://registry.npmjs.org/@openui5/types/-/types-1.115.1.tgz", + "integrity": "sha512-hasF0Dyv5kRyMe8coA45zleo69/ux8F+s4NuaqcE/teG1zC/ZjsnC2erSreM1HCQYs6BzgFgk7S3SRV9OS2/ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/jquery": "3.5.13", + "@types/qunit": "2.5.4" + } + }, + "node_modules/@types/jquery": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.13.tgz", + "integrity": "sha512-ZxJrup8nz/ZxcU0vantG+TPdboMhB24jad2uSap50zE7Q9rUeYlCF25kFMSmHR33qoeOgqcdHEp3roaookC0Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sizzle": "*" + } + }, + "node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-transform-async-to-promises": { + "version": "0.8.18", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.18.tgz", + "integrity": "sha512-WpOrF76nUHijnNn10eBGOHZmXQC8JYRME9rOLxStOga7Av2VO53ehVFvVNImMksVtQuL2/7ZNxEgxnx7oo/3Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-transform-modules-ui5": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-modules-ui5/-/babel-plugin-transform-modules-ui5-7.8.1.tgz", + "integrity": "sha512-rp8TrQkPjKNOSsRyyH+VMqsTs9yZzslgMvF4x3cAM6pwGcdycolMQJ0RO2EGbOhN2eDeiN6ShN72UozZnk191g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-flatten": "^3.0.0", + "doctrine": "^3.0.0", + "ignore-case": "^0.1.0", + "object-assign-defined": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/babel-plugin-transform-remove-console": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", + "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-preset-transform-ui5": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/babel-preset-transform-ui5/-/babel-preset-transform-ui5-7.8.1.tgz", + "integrity": "sha512-OF7e2ahVE/pYT9B/lX/XdewZI52kdzYnCjGdorsLLX1xq8cKeUmC9pQuQCb/su1M6HLmT2OyFCk1DEeiOikuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-transform-modules-ui5": "^7.8.1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore-case": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ignore-case/-/ignore-case-0.1.0.tgz", + "integrity": "sha512-tQS9ucNf134w040JaMWzQ0WXfDR8Vdelk8E6ITviSzE6cOY2K12kNU04lLa8yy9WtcRrKWh3sdv0Xn8uLbMjUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign-defined": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object-assign-defined/-/object-assign-defined-1.2.0.tgz", + "integrity": "sha512-9hzHOUnV8YoBsLB07KhqehUWdeUUW8nyP1j0kPluxXWpoXD6NNyiJNRa3YES0Ds32z+mZtUL/Wqbj+CCLDXJgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ui5-tooling-transpile": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/ui5-tooling-transpile/-/ui5-tooling-transpile-3.11.0.tgz", + "integrity": "sha512-cy9STzBVNzqZebDV9pQR3vifSkk3ZULTDxovAlXm8VSOgD5rvdW91ESg/zKMHr8pI6zmTwRAoQZrIILf2nr09A==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/preset-env": "^7.29.0", + "@babel/preset-typescript": "^7.28.5", + "babel-plugin-transform-async-to-promises": "^0.8.18", + "babel-plugin-transform-remove-console": "^6.9.4", + "babel-preset-transform-ui5": "^7.8.1", + "browserslist": "^4.28.1", + "comment-json": "^4.6.2", + "js-yaml": "^4.1.1" + }, + "peerDependencies": { + "@ui5/ts-interface-generator": ">=0.8.0" + }, + "peerDependenciesMeta": { + "@ui5/ts-interface-generator": { + "optional": true + } + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/package.json b/internal/e2e-tests/fixtures/application.a.ts/package.json new file mode 100644 index 00000000000..b78d455925e --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/package.json @@ -0,0 +1,10 @@ +{ + "name": "application.a.ts", + "version": "1.0.0", + "description": "UI5 Application: application.a.ts", + "license": "Apache-2.0", + "devDependencies": { + "@openui5/types": "1.115.1", + "ui5-tooling-transpile": "^3.11.0" + } +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json b/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json new file mode 100644 index 00000000000..76f47447feb --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "node", + "skipLibCheck": true, + "allowJs": true, + "strict": true, + "strictNullChecks": false, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "types": ["@openui5/types", "@types/qunit"], + "paths": { + "application/a/ts/*": ["./webapp/*"], + "unit/*": ["./webapp/test/unit/*"], + "integration/*": ["./webapp/test/integration/*"] + } + }, + "include": ["./webapp/**/*"], + "exclude": ["./webapp/coverage/**/*"] +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5.yaml b/internal/e2e-tests/fixtures/application.a.ts/ui5.yaml new file mode 100644 index 00000000000..6b6160533ca --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "5.0" +metadata: + name: application.a.ts +type: application +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression diff --git a/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts b/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts new file mode 100644 index 00000000000..e20a74790f2 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts @@ -0,0 +1,22 @@ +type randomTSType = { + first: { + a: number, + b: number, + c: number + }, + second: string +} + +export default class Main { + onInit(): void { + const z : randomTSType = { + first: { + a: 1, + b: 2, + c: 3 + }, + second: "test" + }; + console.log(z.first.a); + } +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json b/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json new file mode 100644 index 00000000000..ae5be89bac7 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json @@ -0,0 +1,66 @@ +{ + "_version": "1.12.0", + "sap.app": { + "id": "application.a.ts", + "type": "application", + "i18n": "i18n/i18n.properties", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "icons": {}, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "rootView": { + "viewName": "application.a.ts.view.App", + "type": "XML", + "async": true, + "id": "app" + }, + "handleValidation": true, + "contentDensities": { + "compact": true, + "cozy": true + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "application.a.ts.i18n.i18n" + } + } + }, + "routing": { + "config": { + "routerClass": "sap.m.routing.Router", + "viewType": "XML", + "viewPath": "application.a.ts.view", + "controlId": "app", + "controlAggregation": "pages", + "async": true + }, + "routes": [ + { + "pattern": "", + "name": "main", + "target": "main" + } + ], + "targets": { + "main": { + "viewId": "main", + "viewName": "Main" + } + } + } + } +} diff --git a/internal/e2e-tests/package.json b/internal/e2e-tests/package.json new file mode 100644 index 00000000000..99094fd9898 --- /dev/null +++ b/internal/e2e-tests/package.json @@ -0,0 +1,20 @@ +{ + "name": "@ui5-internal/e2e-tests", + "private": true, + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": "^22.20.0 || >=24.0.0", + "npm": ">= 8" + }, + "scripts": { + "test": "npm run lint && npm run coverage", + "unit": "node --test 'test/**/*.js'", + "unit-watch": "node --test --watch 'test/**/*.js'", + "coverage": "node --test --experimental-test-coverage 'test/**/*.js'", + "lint": "eslint ." + }, + "devDependencies": { + "eslint": "^9.39.1" + } +} diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js new file mode 100644 index 00000000000..72c095f886c --- /dev/null +++ b/internal/e2e-tests/test/build.js @@ -0,0 +1,52 @@ +// Test running "ui5 build" +// with fixtures (under ../fixtures) +// and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. + +import { exec } from "node:child_process"; +import {test, describe} from "node:test"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; +import fs from "node:fs/promises"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ui5CliPath = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); + +class FixtureHelper { + constructor(fixtureName) { + this.fixtureName = fixtureName; + this.originFixturePath = path.resolve(__dirname, "../fixtures", fixtureName); + this.tmpPath = path.resolve(__dirname, "../tmp", fixtureName); + this.dotUi5Path = path.resolve(this.tmpPath, ".ui5"); + this.distPath = path.resolve(this.tmpPath, "dist"); + } + + async init() { + await fs.rm(this.tmpPath, {recursive: true, force: true}); + await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); + } +} + +describe("ui5 build", () => { + test("run the UI5 build command", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a.ts"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + await new Promise((resolve, reject) => { + exec(`node ${ui5CliPath} build --dest ${fixtureHelper.distPath}`, async (error, stdout, stderr) => { + if (error) { + assert.fail(error); + reject(error); + return; + } + resolve(); + }); + }); + + // Test: no TS syntax is left in the preload (transpile + preload tasks succeeeded) + const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(componentPreload.includes("application/a/ts/controller/Test.controller"), "Component-preload.js should contain the TS resource transpiled to JS"); + assert.ok(!componentPreload.includes("randomTSType"), "Component-preload.js should not contain any TS syntax"); + }); +}); diff --git a/internal/e2e-tests/test/version.js b/internal/e2e-tests/test/version.js new file mode 100644 index 00000000000..4d9414ad61f --- /dev/null +++ b/internal/e2e-tests/test/version.js @@ -0,0 +1,31 @@ +// Test running "ui5 --version" +// without any fixtures or additional setup. +// and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. + +import { exec } from "node:child_process"; +import {test, describe} from "node:test"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("ui5 version", () => { + test("output the version of the UI5 CLI", ({assert}) => { + const ui5Path = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); + exec(`node ${ui5Path} --version`, (error, stdout, stderr) => { + if (error) { + assert.fail(error); + return; + } + if (stderr) { + assert.fail(new Error(stderr)); + return; + } + // Test: the expected CLI version output is printed + // e.g. "5.0.0 (from /path/to/ui5/cli)" + const outPattern = /\d+\..* \(from .*\)/; + assert.ok(stdout.match(outPattern)); + }); + }); +}); From 052bc00620d72245a8890e9be492c31f17f517ea Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 1 Apr 2026 12:10:19 +0200 Subject: [PATCH 190/223] test: E2E-tests: Fix child process security + Include "npm install" in test runtime --- internal/e2e-tests/README.md | 7 +++++++ internal/e2e-tests/test/build.js | 20 +++++++++++++++++--- internal/e2e-tests/test/version.js | 4 ++-- 3 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 internal/e2e-tests/README.md diff --git a/internal/e2e-tests/README.md b/internal/e2e-tests/README.md new file mode 100644 index 00000000000..e94d9cce35c --- /dev/null +++ b/internal/e2e-tests/README.md @@ -0,0 +1,7 @@ +This directory is an End-To-End test environment for the UI5 CLI containing realistic user scenarios (1) and test projects under "./fixtures" (2). + +1 - Test for "ui5 --version" & Test for "ui5 build"
+2 - "application.a.ts": Sample typescript project (modified version from [generator-ui5-ts-app](https://github.com/ui5-community/generator-ui5-ts-app)) + + +Currently, these tests are NOT included in any CI test pipeline. diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js index 72c095f886c..5d657738895 100644 --- a/internal/e2e-tests/test/build.js +++ b/internal/e2e-tests/test/build.js @@ -2,7 +2,7 @@ // with fixtures (under ../fixtures) // and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. -import { exec } from "node:child_process"; +import { execFile } from "node:child_process"; import {test, describe} from "node:test"; import {fileURLToPath} from "node:url"; import path from "node:path"; @@ -22,8 +22,20 @@ class FixtureHelper { } async init() { + // Clean up previous runs await fs.rm(this.tmpPath, {recursive: true, force: true}); + // Copy fixture to temp location await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); + // Install node_modules + await new Promise((resolve, reject) => { + execFile("npm", ["install"], { cwd: this.tmpPath }, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); } } @@ -34,7 +46,7 @@ describe("ui5 build", () => { process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; process.chdir(fixtureHelper.tmpPath); await new Promise((resolve, reject) => { - exec(`node ${ui5CliPath} build --dest ${fixtureHelper.distPath}`, async (error, stdout, stderr) => { + execFile("node", [ui5CliPath, "build", "--dest", fixtureHelper.distPath], async (error, stdout, stderr) => { if (error) { assert.fail(error); reject(error); @@ -47,6 +59,8 @@ describe("ui5 build", () => { // Test: no TS syntax is left in the preload (transpile + preload tasks succeeeded) const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); assert.ok(componentPreload.includes("application/a/ts/controller/Test.controller"), "Component-preload.js should contain the TS resource transpiled to JS"); - assert.ok(!componentPreload.includes("randomTSType"), "Component-preload.js should not contain any TS syntax"); + assert.ok(!componentPreload.includes("randomTSType"), "Component-preload.js should NOT contain any TS syntax"); + const componentPreloadMap = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js.map"), "utf-8"); + assert.ok(componentPreloadMap.includes("randomTSType"), "Component-preload.js.map should contain the TS type information"); }); }); diff --git a/internal/e2e-tests/test/version.js b/internal/e2e-tests/test/version.js index 4d9414ad61f..30c2eb9e22e 100644 --- a/internal/e2e-tests/test/version.js +++ b/internal/e2e-tests/test/version.js @@ -2,7 +2,7 @@ // without any fixtures or additional setup. // and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. -import { exec } from "node:child_process"; +import { execFile } from "node:child_process"; import {test, describe} from "node:test"; import {fileURLToPath} from "node:url"; import path from "node:path"; @@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename); describe("ui5 version", () => { test("output the version of the UI5 CLI", ({assert}) => { const ui5Path = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); - exec(`node ${ui5Path} --version`, (error, stdout, stderr) => { + execFile("node", [ui5Path, "--version"], (error, stdout, stderr) => { if (error) { assert.fail(error); return; From c8094fd6cc602b4486a196d6484e8d15f34ec01b Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 2 Apr 2026 17:17:17 +0200 Subject: [PATCH 191/223] test: E2E-tests: Cover scenario for "ui5-task-zipper" + Refactor test environment for better reuse + Extend typescript test to cover Incremental Build --- .../application.a.ts/package-lock.json | 2337 ----------------- .../fixtures/application.a.ts/package.json | 3 +- .../application.a.ts/ui5-task-zipper.yaml | 10 + ... => ui5-tooling-transpile-middleware.yaml} | 0 internal/e2e-tests/package.json | 3 + internal/e2e-tests/test/build.js | 109 +- package-lock.json | 27 + 7 files changed, 142 insertions(+), 2347 deletions(-) delete mode 100644 internal/e2e-tests/fixtures/application.a.ts/package-lock.json create mode 100644 internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml rename internal/e2e-tests/fixtures/application.a.ts/{ui5.yaml => ui5-tooling-transpile-middleware.yaml} (100%) diff --git a/internal/e2e-tests/fixtures/application.a.ts/package-lock.json b/internal/e2e-tests/fixtures/application.a.ts/package-lock.json deleted file mode 100644 index d4a4e2640ed..00000000000 --- a/internal/e2e-tests/fixtures/application.a.ts/package-lock.json +++ /dev/null @@ -1,2337 +0,0 @@ -{ - "name": "application.a.ts", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "application.a.ts", - "version": "1.0.0", - "license": "Apache-2.0", - "devDependencies": { - "@openui5/types": "1.115.1", - "ui5-tooling-transpile": "^3.11.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", - "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@openui5/types": { - "version": "1.115.1", - "resolved": "https://registry.npmjs.org/@openui5/types/-/types-1.115.1.tgz", - "integrity": "sha512-hasF0Dyv5kRyMe8coA45zleo69/ux8F+s4NuaqcE/teG1zC/ZjsnC2erSreM1HCQYs6BzgFgk7S3SRV9OS2/ig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/jquery": "3.5.13", - "@types/qunit": "2.5.4" - } - }, - "node_modules/@types/jquery": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.13.tgz", - "integrity": "sha512-ZxJrup8nz/ZxcU0vantG+TPdboMhB24jad2uSap50zE7Q9rUeYlCF25kFMSmHR33qoeOgqcdHEp3roaookC0Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sizzle": "*" - } - }, - "node_modules/@types/qunit": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", - "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/sizzle": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", - "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", - "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", - "dev": true, - "license": "MIT" - }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-transform-async-to-promises": { - "version": "0.8.18", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.18.tgz", - "integrity": "sha512-WpOrF76nUHijnNn10eBGOHZmXQC8JYRME9rOLxStOga7Av2VO53ehVFvVNImMksVtQuL2/7ZNxEgxnx7oo/3Hw==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-plugin-transform-modules-ui5": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-modules-ui5/-/babel-plugin-transform-modules-ui5-7.8.1.tgz", - "integrity": "sha512-rp8TrQkPjKNOSsRyyH+VMqsTs9yZzslgMvF4x3cAM6pwGcdycolMQJ0RO2EGbOhN2eDeiN6ShN72UozZnk191g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-flatten": "^3.0.0", - "doctrine": "^3.0.0", - "ignore-case": "^0.1.0", - "object-assign-defined": "^1.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/babel-plugin-transform-remove-console": { - "version": "6.9.4", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", - "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-preset-transform-ui5": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/babel-preset-transform-ui5/-/babel-preset-transform-ui5-7.8.1.tgz", - "integrity": "sha512-OF7e2ahVE/pYT9B/lX/XdewZI52kdzYnCjGdorsLLX1xq8cKeUmC9pQuQCb/su1M6HLmT2OyFCk1DEeiOikuAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-transform-modules-ui5": "^7.8.1" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/comment-json": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", - "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", - "dev": true, - "license": "ISC" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore-case": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/ignore-case/-/ignore-case-0.1.0.tgz", - "integrity": "sha512-tQS9ucNf134w040JaMWzQ0WXfDR8Vdelk8E6ITviSzE6cOY2K12kNU04lLa8yy9WtcRrKWh3sdv0Xn8uLbMjUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign-defined": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object-assign-defined/-/object-assign-defined-1.2.0.tgz", - "integrity": "sha512-9hzHOUnV8YoBsLB07KhqehUWdeUUW8nyP1j0kPluxXWpoXD6NNyiJNRa3YES0Ds32z+mZtUL/Wqbj+CCLDXJgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ui5-tooling-transpile": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/ui5-tooling-transpile/-/ui5-tooling-transpile-3.11.0.tgz", - "integrity": "sha512-cy9STzBVNzqZebDV9pQR3vifSkk3ZULTDxovAlXm8VSOgD5rvdW91ESg/zKMHr8pI6zmTwRAoQZrIILf2nr09A==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/preset-env": "^7.29.0", - "@babel/preset-typescript": "^7.28.5", - "babel-plugin-transform-async-to-promises": "^0.8.18", - "babel-plugin-transform-remove-console": "^6.9.4", - "babel-preset-transform-ui5": "^7.8.1", - "browserslist": "^4.28.1", - "comment-json": "^4.6.2", - "js-yaml": "^4.1.1" - }, - "peerDependencies": { - "@ui5/ts-interface-generator": ">=0.8.0" - }, - "peerDependenciesMeta": { - "@ui5/ts-interface-generator": { - "optional": true - } - } - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/internal/e2e-tests/fixtures/application.a.ts/package.json b/internal/e2e-tests/fixtures/application.a.ts/package.json index b78d455925e..119d312ea47 100644 --- a/internal/e2e-tests/fixtures/application.a.ts/package.json +++ b/internal/e2e-tests/fixtures/application.a.ts/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "devDependencies": { "@openui5/types": "1.115.1", - "ui5-tooling-transpile": "^3.11.0" + "ui5-task-zipper": "3.6.0", + "ui5-tooling-transpile": "3.11.0" } } diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml b/internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml new file mode 100644 index 00000000000..8f24fb3f125 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml @@ -0,0 +1,10 @@ +specVersion: "5.0" +metadata: + name: application.a.ts +type: application +builder: + customTasks: + - name: ui5-task-zipper + afterTask: generateVersionInfo + configuration: + archiveName: "webapp" diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5.yaml b/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile-middleware.yaml similarity index 100% rename from internal/e2e-tests/fixtures/application.a.ts/ui5.yaml rename to internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile-middleware.yaml diff --git a/internal/e2e-tests/package.json b/internal/e2e-tests/package.json index 99094fd9898..5e942dc30b4 100644 --- a/internal/e2e-tests/package.json +++ b/internal/e2e-tests/package.json @@ -16,5 +16,8 @@ }, "devDependencies": { "eslint": "^9.39.1" + }, + "dependencies": { + "adm-zip": "^0.5.17" } } diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js index 5d657738895..a3972b288a6 100644 --- a/internal/e2e-tests/test/build.js +++ b/internal/e2e-tests/test/build.js @@ -7,6 +7,7 @@ import {test, describe} from "node:test"; import {fileURLToPath} from "node:url"; import path from "node:path"; import fs from "node:fs/promises"; +import AdmZip from "adm-zip"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -24,9 +25,29 @@ class FixtureHelper { async init() { // Clean up previous runs await fs.rm(this.tmpPath, {recursive: true, force: true}); - // Copy fixture to temp location + // Copy source files to temp location await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); // Install node_modules + await this._installNodeModules(); + } + + async prepareForNextRun() { + // Delete everything from the tmp/ folder except .ui5 & dist folders + const entries = await fs.readdir(this.tmpPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".ui5" || entry.name === "dist") { + continue; + } + const entryPath = path.resolve(this.tmpPath, entry.name); + await fs.rm(entryPath, { recursive: true, force: true }); + } + // Copy source files to temp location + await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); + // Install node_modules + await this._installNodeModules(); + } + + async _installNodeModules() { await new Promise((resolve, reject) => { execFile("npm", ["install"], { cwd: this.tmpPath }, (error, stdout, stderr) => { if (error) { @@ -37,16 +58,10 @@ class FixtureHelper { }); }); } -} -describe("ui5 build", () => { - test("run the UI5 build command", async ({assert}) => { - const fixtureHelper = new FixtureHelper("application.a.ts"); - await fixtureHelper.init(); - process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; - process.chdir(fixtureHelper.tmpPath); + async build(assert, ui5YamlName) { await new Promise((resolve, reject) => { - execFile("node", [ui5CliPath, "build", "--dest", fixtureHelper.distPath], async (error, stdout, stderr) => { + execFile("node", [ui5CliPath, "build", "--config", ui5YamlName, "--dest", this.distPath], async (error, stdout, stderr) => { if (error) { assert.fail(error); reject(error); @@ -55,6 +70,19 @@ describe("ui5 build", () => { resolve(); }); }); + } +} + +describe("ui5 build", () => { + test("ui5-tooling-transpile-middleware", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a.ts"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-tooling-transpile-middleware.yaml"; + + // #1 Build + await fixtureHelper.build(assert, ui5YamlName); // Test: no TS syntax is left in the preload (transpile + preload tasks succeeeded) const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); @@ -62,5 +90,68 @@ describe("ui5 build", () => { assert.ok(!componentPreload.includes("randomTSType"), "Component-preload.js should NOT contain any TS syntax"); const componentPreloadMap = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js.map"), "utf-8"); assert.ok(componentPreloadMap.includes("randomTSType"), "Component-preload.js.map should contain the TS type information"); + + // -------------------------------------------------------------------------------------------- + + // Modify source files + await fixtureHelper.prepareForNextRun(); + const fileToModify = path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.ts"); + const fileContent = await fs.readFile(fileToModify, "utf-8"); + const modifiedContent = fileContent.replace("second: \"test\"", "second: \"test_2\""); + await fs.writeFile(fileToModify, modifiedContent, "utf-8"); + + // #2 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the modified content is reflected in the new build output (transpile + preload tasks succeeeded) + const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(newComponentPreload.includes("second:\"test_2\""), "Component-preload.js should contain the updated content from the modified source file"); + }); + + test.only("ui5-task-zipper", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a.ts"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-task-zipper.yaml"; + + // #1 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the zip file is created in the dist folder + const zipFilePath = path.resolve(fixtureHelper.distPath, "webapp.zip"); + const zipFileExists = await fs.access(zipFilePath).then(() => true).catch(() => false); + assert.ok(zipFileExists, "The zip file should be created in the dist folder"); + + // Check the archive content + const zip = new AdmZip(zipFilePath); + const zipEntries = zip.getEntries(); + assert.ok(zipEntries.length > 0, "The zip file should contain entries"); + + // Check that the zip file contains the expected source file + const testControllerEntry = zipEntries.find(entry => entry.entryName === "controller/Test.controller.ts"); + assert.ok(testControllerEntry, "The zip file should contain the expected source file"); + + // -------------------------------------------------------------------------------------------- + + // Delete a source file + await fixtureHelper.prepareForNextRun(); + await fs.rm(path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.ts")); + + // #2 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the zip file is updated and does not contain the deleted file + const newZipFileExists = await fs.access(zipFilePath).then(() => true).catch(() => false); + assert.ok(newZipFileExists, "The zip file should be created in the dist folder after the second build"); + + // Check the archive content + const zip2 = new AdmZip(zipFilePath); + const zipEntries2 = zip2.getEntries(); + assert.ok(zipEntries2.length > 0, "The zip file should contain entries after the second build"); + + // Check that the zip file does NOT contain the expected source file anymore + const deletedTestControllerEntry = zipEntries2.find(entry => entry.entryName === "controller/Test.controller.ts"); + assert.ok(!deletedTestControllerEntry, "The zip file should NOT contain the deleted source file"); }); }); diff --git a/package-lock.json b/package-lock.json index 8112c408236..40174916c21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,20 @@ "npm": ">= 8" } }, + "internal/e2e-tests": { + "name": "@ui5-internal/e2e-tests", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.17" + }, + "devDependencies": { + "eslint": "^9.39.1" + }, + "engines": { + "node": "^22.20.0 || >=24.0.0", + "npm": ">= 8" + } + }, "internal/shrinkwrap-extractor": { "name": "@ui5/shrinkwrap-extractor", "version": "1.0.0", @@ -4262,6 +4276,10 @@ "resolved": "internal/benchmark", "link": true }, + "node_modules/@ui5-internal/e2e-tests": { + "resolved": "internal/e2e-tests", + "link": true + }, "node_modules/@ui5/builder": { "resolved": "packages/builder", "link": true @@ -4619,6 +4637,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "license": "MIT", From 2064e8478cbd3c3250b963057c0e691adf96a38a Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 2 Apr 2026 18:13:47 +0200 Subject: [PATCH 192/223] test: E2E-tests: Cover scenario for "ui5-tooling-modules" + Add "application.a" (Javascript project fixture) --- .../fixtures/application.a.ts/package.json | 1 - ...leware.yaml => ui5-tooling-transpile.yaml} | 0 .../fixtures/application.a/package.json | 14 ++++ .../ui5-task-zipper.yaml | 2 +- .../application.a/ui5-tooling-modules.yaml | 12 ++++ .../webapp/controller/Test.controller.js | 18 +++++ .../application.a/webapp/manifest.json | 66 +++++++++++++++++++ internal/e2e-tests/test/build.js | 53 +++++++++++++-- 8 files changed, 157 insertions(+), 9 deletions(-) rename internal/e2e-tests/fixtures/application.a.ts/{ui5-tooling-transpile-middleware.yaml => ui5-tooling-transpile.yaml} (100%) create mode 100644 internal/e2e-tests/fixtures/application.a/package.json rename internal/e2e-tests/fixtures/{application.a.ts => application.a}/ui5-task-zipper.yaml (88%) create mode 100644 internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml create mode 100644 internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js create mode 100644 internal/e2e-tests/fixtures/application.a/webapp/manifest.json diff --git a/internal/e2e-tests/fixtures/application.a.ts/package.json b/internal/e2e-tests/fixtures/application.a.ts/package.json index 119d312ea47..24b042192ad 100644 --- a/internal/e2e-tests/fixtures/application.a.ts/package.json +++ b/internal/e2e-tests/fixtures/application.a.ts/package.json @@ -5,7 +5,6 @@ "license": "Apache-2.0", "devDependencies": { "@openui5/types": "1.115.1", - "ui5-task-zipper": "3.6.0", "ui5-tooling-transpile": "3.11.0" } } diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile-middleware.yaml b/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml similarity index 100% rename from internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile-middleware.yaml rename to internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml diff --git a/internal/e2e-tests/fixtures/application.a/package.json b/internal/e2e-tests/fixtures/application.a/package.json new file mode 100644 index 00000000000..6291a694808 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/package.json @@ -0,0 +1,14 @@ +{ + "name": "application.a", + "version": "1.0.0", + "description": "UI5 Application: application.a", + "license": "Apache-2.0", + "dependencies": { + "chart.js": "4.5.1" + }, + "devDependencies": { + "@openui5/types": "1.115.1", + "ui5-task-zipper": "3.6.0", + "ui5-tooling-modules": "3.35.0" + } +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml b/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml similarity index 88% rename from internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml rename to internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml index 8f24fb3f125..b217eb59ec7 100644 --- a/internal/e2e-tests/fixtures/application.a.ts/ui5-task-zipper.yaml +++ b/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml @@ -1,6 +1,6 @@ specVersion: "5.0" metadata: - name: application.a.ts + name: application.a type: application builder: customTasks: diff --git a/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml b/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml new file mode 100644 index 00000000000..943ea39c81a --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml @@ -0,0 +1,12 @@ +specVersion: "5.0" +metadata: + name: application.a +type: application +server: + customMiddleware: + - name: ui5-tooling-modules-middleware + afterMiddleware: compression +builder: + customTasks: + - name: ui5-tooling-modules-task + afterTask: replaceVersion diff --git a/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js new file mode 100644 index 00000000000..2ae092e67ea --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js @@ -0,0 +1,18 @@ +sap.ui.define(["chart.js"], (chartJS) => { + return Controller.extend("application.a.controller.Test",{ + onInit() { + const z = { + first: { + a: 1, + b: 2, + c: 3 + }, + second: "test" + }; + console.log(z.first.a); + + // For "ui5-tooling-modules" test: + console.log(chartJS); + } + }); +}); diff --git a/internal/e2e-tests/fixtures/application.a/webapp/manifest.json b/internal/e2e-tests/fixtures/application.a/webapp/manifest.json new file mode 100644 index 00000000000..58eb693c97f --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/webapp/manifest.json @@ -0,0 +1,66 @@ +{ + "_version": "1.12.0", + "sap.app": { + "id": "application.a", + "type": "application", + "i18n": "i18n/i18n.properties", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "icons": {}, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "rootView": { + "viewName": "application.a.view.App", + "type": "XML", + "async": true, + "id": "app" + }, + "handleValidation": true, + "contentDensities": { + "compact": true, + "cozy": true + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "application.a.i18n.i18n" + } + } + }, + "routing": { + "config": { + "routerClass": "sap.m.routing.Router", + "viewType": "XML", + "viewPath": "application.a.view", + "controlId": "app", + "controlAggregation": "pages", + "async": true + }, + "routes": [ + { + "pattern": "", + "name": "main", + "target": "main" + } + ], + "targets": { + "main": { + "viewId": "main", + "viewName": "Main" + } + } + } + } +} diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js index a3972b288a6..076d0807e0b 100644 --- a/internal/e2e-tests/test/build.js +++ b/internal/e2e-tests/test/build.js @@ -74,12 +74,12 @@ class FixtureHelper { } describe("ui5 build", () => { - test("ui5-tooling-transpile-middleware", async ({assert}) => { + test("ui5-tooling-transpile", async ({assert}) => { const fixtureHelper = new FixtureHelper("application.a.ts"); await fixtureHelper.init(); process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; process.chdir(fixtureHelper.tmpPath); - const ui5YamlName = "ui5-tooling-transpile-middleware.yaml"; + const ui5YamlName = "ui5-tooling-transpile.yaml"; // #1 Build await fixtureHelper.build(assert, ui5YamlName); @@ -108,8 +108,8 @@ describe("ui5 build", () => { assert.ok(newComponentPreload.includes("second:\"test_2\""), "Component-preload.js should contain the updated content from the modified source file"); }); - test.only("ui5-task-zipper", async ({assert}) => { - const fixtureHelper = new FixtureHelper("application.a.ts"); + test("ui5-task-zipper", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a"); await fixtureHelper.init(); process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; process.chdir(fixtureHelper.tmpPath); @@ -129,14 +129,14 @@ describe("ui5 build", () => { assert.ok(zipEntries.length > 0, "The zip file should contain entries"); // Check that the zip file contains the expected source file - const testControllerEntry = zipEntries.find(entry => entry.entryName === "controller/Test.controller.ts"); + const testControllerEntry = zipEntries.find(entry => entry.entryName === "controller/Test.controller.js"); assert.ok(testControllerEntry, "The zip file should contain the expected source file"); // -------------------------------------------------------------------------------------------- // Delete a source file await fixtureHelper.prepareForNextRun(); - await fs.rm(path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.ts")); + await fs.rm(path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.js")); // #2 Build await fixtureHelper.build(assert, ui5YamlName); @@ -151,7 +151,46 @@ describe("ui5 build", () => { assert.ok(zipEntries2.length > 0, "The zip file should contain entries after the second build"); // Check that the zip file does NOT contain the expected source file anymore - const deletedTestControllerEntry = zipEntries2.find(entry => entry.entryName === "controller/Test.controller.ts"); + const deletedTestControllerEntry = zipEntries2.find(entry => entry.entryName === "controller/Test.controller.js"); assert.ok(!deletedTestControllerEntry, "The zip file should NOT contain the deleted source file"); }); + + test("ui5-tooling-modules", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-tooling-modules.yaml"; + + // #1 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the dist contains the expected preload with the correct content + const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(componentPreload.includes("sap.ui.predefine(\"application/a/controller/Test.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'Test' controller and its dependency"); + + + // -------------------------------------------------------------------------------------------- + + // Add a new source file with another third party import + await fixtureHelper.prepareForNextRun(); + const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); + const newControllerContent = +`sap.ui.define(["chart.js"], (chartJS) => { + return Controller.extend("application.a.controller.New",{ + onInit() { + console.log(chartJS); + } + }); +});`; + await fs.writeFile(newControllerPath, newControllerContent, "utf-8"); + + // #2 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the dist contains the new controller and the new import + const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/Test.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'Test' controller and its dependency"); + assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/New.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'New' controller and its dependency"); + }); }); From c940d1621a8857d34ee8e45990ee0f202e7c6a1f Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 2 Apr 2026 19:18:38 +0200 Subject: [PATCH 193/223] test: E2E-tests: Cover scenario for "ui5-tooling-stringreplace" (Currently FAILING) + Refactor "ui5-tooling-modules" test --- .../fixtures/application.a/package.json | 3 +- .../ui5-tooling-stringreplace.yaml | 14 ++++ .../webapp/controller/Test.controller.js | 6 +- internal/e2e-tests/test/build.js | 68 ++++++++++++++----- 4 files changed, 68 insertions(+), 23 deletions(-) create mode 100644 internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml diff --git a/internal/e2e-tests/fixtures/application.a/package.json b/internal/e2e-tests/fixtures/application.a/package.json index 6291a694808..4c8a19f1593 100644 --- a/internal/e2e-tests/fixtures/application.a/package.json +++ b/internal/e2e-tests/fixtures/application.a/package.json @@ -9,6 +9,7 @@ "devDependencies": { "@openui5/types": "1.115.1", "ui5-task-zipper": "3.6.0", - "ui5-tooling-modules": "3.35.0" + "ui5-tooling-modules": "3.35.0", + "ui5-tooling-stringreplace": "3.6.0" } } diff --git a/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml b/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml new file mode 100644 index 00000000000..36fad93ceee --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml @@ -0,0 +1,14 @@ +specVersion: "5.0" +metadata: + name: application.a +type: application +builder: + customTasks: + - name: ui5-tooling-stringreplace-task + afterTask: replaceVersion + configuration: + files: + - "**/*.js" + replace: + - placeholder: ${PLACEHOLDER_TEXT} + value: "'INSERTED_TEXT'" diff --git a/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js index 2ae092e67ea..8b0befdce30 100644 --- a/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js +++ b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js @@ -1,4 +1,4 @@ -sap.ui.define(["chart.js"], (chartJS) => { +sap.ui.define([], () => { return Controller.extend("application.a.controller.Test",{ onInit() { const z = { @@ -10,9 +10,7 @@ sap.ui.define(["chart.js"], (chartJS) => { second: "test" }; console.log(z.first.a); - - // For "ui5-tooling-modules" test: - console.log(chartJS); } }); }); + diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js index 076d0807e0b..ed90436cdfa 100644 --- a/internal/e2e-tests/test/build.js +++ b/internal/e2e-tests/test/build.js @@ -35,7 +35,7 @@ class FixtureHelper { // Delete everything from the tmp/ folder except .ui5 & dist folders const entries = await fs.readdir(this.tmpPath, { withFileTypes: true }); for (const entry of entries) { - if (entry.name === ".ui5" || entry.name === "dist") { + if (entry.name === ".ui5" || entry.name === "dist" || entry.name === "node_modules") { continue; } const entryPath = path.resolve(this.tmpPath, entry.name); @@ -43,14 +43,13 @@ class FixtureHelper { } // Copy source files to temp location await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); - // Install node_modules - await this._installNodeModules(); } - async _installNodeModules() { + async build(assert, ui5YamlName) { await new Promise((resolve, reject) => { - execFile("npm", ["install"], { cwd: this.tmpPath }, (error, stdout, stderr) => { + execFile("node", [ui5CliPath, "build", "--config", ui5YamlName, "--dest", this.distPath], async (error, stdout, stderr) => { if (error) { + assert.fail(error); reject(error); return; } @@ -59,11 +58,10 @@ class FixtureHelper { }); } - async build(assert, ui5YamlName) { + async _installNodeModules() { await new Promise((resolve, reject) => { - execFile("node", [ui5CliPath, "build", "--config", ui5YamlName, "--dest", this.distPath], async (error, stdout, stderr) => { + execFile("npm", ["install"], { cwd: this.tmpPath }, (error, stdout, stderr) => { if (error) { - assert.fail(error); reject(error); return; } @@ -162,17 +160,12 @@ describe("ui5 build", () => { process.chdir(fixtureHelper.tmpPath); const ui5YamlName = "ui5-tooling-modules.yaml"; - // #1 Build + // #1 Build (no thirdparty module yet -> just checking that the build succeeds) await fixtureHelper.build(assert, ui5YamlName); - // Test: the dist contains the expected preload with the correct content - const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); - assert.ok(componentPreload.includes("sap.ui.predefine(\"application/a/controller/Test.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'Test' controller and its dependency"); - - // -------------------------------------------------------------------------------------------- - // Add a new source file with another third party import + // Add a new source file with a third party import await fixtureHelper.prepareForNextRun(); const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); const newControllerContent = @@ -188,9 +181,48 @@ describe("ui5 build", () => { // #2 Build await fixtureHelper.build(assert, ui5YamlName); - // Test: the dist contains the new controller and the new import + // Test: the dist contains the new controller and the third party import const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); - assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/Test.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'Test' controller and its dependency"); - assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/New.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'New' controller and its dependency"); + assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/New.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'New' controller and chart.js"); + }); + + // FIXME: Currently failing + test("ui5-tooling-stringreplace", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-tooling-stringreplace.yaml"; + + // #1 Build (no string replacing yet -> just checking that the build succeeds) + await fixtureHelper.build(assert, ui5YamlName); + + // -------------------------------------------------------------------------------------------- + + + // Add a new source file with a placeholder string + await fixtureHelper.prepareForNextRun(); + const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); + const newControllerContent = +`sap.ui.define([], () => { + return Controller.extend("application.a.controller.New",{ + onInit() { + console.log(\${PLACEHOLDER_TEXT}); + } + }); +});`; + await fs.writeFile(newControllerPath, newControllerContent, "utf-8"); + + // #2 Build + // FIXME: Currently failing here (April 02 2026) + // Error message: + // ("Minification failed with error: Unexpected token punc «{», expected punc «,» in file /resources/application/a/controller/New.controller.js (line 4, col 16, pos 114)") + // + // -> Probably, the string replacement doesn't get executed as very first middleware (minify happens earlier unexpectedly) + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the placeholder in the source file is replaced in the dist output + const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(componentPreload.includes("console.log(\"INSERTED_TEXT\")"), "The placeholder should get replaced with the expected text in the component preload"); }); }); From 96cb64d5d865b0bfd3a6e880866ca7ef0a2c407d Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Thu, 2 Apr 2026 19:29:35 +0200 Subject: [PATCH 194/223] test: E2E-tests: Adjust readme --- internal/e2e-tests/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/e2e-tests/README.md b/internal/e2e-tests/README.md index e94d9cce35c..5d400a3c2c4 100644 --- a/internal/e2e-tests/README.md +++ b/internal/e2e-tests/README.md @@ -1,7 +1,9 @@ This directory is an End-To-End test environment for the UI5 CLI containing realistic user scenarios (1) and test projects under "./fixtures" (2). 1 - Test for "ui5 --version" & Test for "ui5 build"
-2 - "application.a.ts": Sample typescript project (modified version from [generator-ui5-ts-app](https://github.com/ui5-community/generator-ui5-ts-app)) +2 - Fixtures included: +- "application.a": Sample javascript project (just contains controller + manifest.json) +- "application.a.ts": Sample typescript project (modified version from [generator-ui5-ts-app](https://github.com/ui5-community/generator-ui5-ts-app)) -Currently, these tests are NOT included in any CI test pipeline. +Currently, these tests are NOT included in any CI pipeline. From ea12cb5320e4014ada9dae5fcad8332582d5c4f9 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Wed, 8 Apr 2026 15:26:49 +0200 Subject: [PATCH 195/223] test: Remove E2E-test --- internal/e2e-tests/README.md | 9 - .../fixtures/application.a.ts/package.json | 10 - .../fixtures/application.a.ts/tsconfig.json | 21 -- .../ui5-tooling-transpile.yaml | 12 - .../webapp/controller/Test.controller.ts | 22 -- .../application.a.ts/webapp/manifest.json | 66 ----- .../fixtures/application.a/package.json | 15 -- .../application.a/ui5-task-zipper.yaml | 10 - .../application.a/ui5-tooling-modules.yaml | 12 - .../ui5-tooling-stringreplace.yaml | 14 -- .../webapp/controller/Test.controller.js | 16 -- .../application.a/webapp/manifest.json | 66 ----- internal/e2e-tests/package.json | 23 -- internal/e2e-tests/test/build.js | 228 ------------------ internal/e2e-tests/test/version.js | 31 --- package-lock.json | 45 ---- 16 files changed, 600 deletions(-) delete mode 100644 internal/e2e-tests/README.md delete mode 100644 internal/e2e-tests/fixtures/application.a.ts/package.json delete mode 100644 internal/e2e-tests/fixtures/application.a.ts/tsconfig.json delete mode 100644 internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml delete mode 100644 internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts delete mode 100644 internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json delete mode 100644 internal/e2e-tests/fixtures/application.a/package.json delete mode 100644 internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml delete mode 100644 internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml delete mode 100644 internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml delete mode 100644 internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js delete mode 100644 internal/e2e-tests/fixtures/application.a/webapp/manifest.json delete mode 100644 internal/e2e-tests/package.json delete mode 100644 internal/e2e-tests/test/build.js delete mode 100644 internal/e2e-tests/test/version.js diff --git a/internal/e2e-tests/README.md b/internal/e2e-tests/README.md deleted file mode 100644 index 5d400a3c2c4..00000000000 --- a/internal/e2e-tests/README.md +++ /dev/null @@ -1,9 +0,0 @@ -This directory is an End-To-End test environment for the UI5 CLI containing realistic user scenarios (1) and test projects under "./fixtures" (2). - -1 - Test for "ui5 --version" & Test for "ui5 build"
-2 - Fixtures included: -- "application.a": Sample javascript project (just contains controller + manifest.json) -- "application.a.ts": Sample typescript project (modified version from [generator-ui5-ts-app](https://github.com/ui5-community/generator-ui5-ts-app)) - - -Currently, these tests are NOT included in any CI pipeline. diff --git a/internal/e2e-tests/fixtures/application.a.ts/package.json b/internal/e2e-tests/fixtures/application.a.ts/package.json deleted file mode 100644 index 24b042192ad..00000000000 --- a/internal/e2e-tests/fixtures/application.a.ts/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "application.a.ts", - "version": "1.0.0", - "description": "UI5 Application: application.a.ts", - "license": "Apache-2.0", - "devDependencies": { - "@openui5/types": "1.115.1", - "ui5-tooling-transpile": "3.11.0" - } -} diff --git a/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json b/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json deleted file mode 100644 index 76f47447feb..00000000000 --- a/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "es2022", - "module": "es2022", - "moduleResolution": "node", - "skipLibCheck": true, - "allowJs": true, - "strict": true, - "strictNullChecks": false, - "strictPropertyInitialization": false, - "rootDir": "./webapp", - "types": ["@openui5/types", "@types/qunit"], - "paths": { - "application/a/ts/*": ["./webapp/*"], - "unit/*": ["./webapp/test/unit/*"], - "integration/*": ["./webapp/test/integration/*"] - } - }, - "include": ["./webapp/**/*"], - "exclude": ["./webapp/coverage/**/*"] -} diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml b/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml deleted file mode 100644 index 6b6160533ca..00000000000 --- a/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml +++ /dev/null @@ -1,12 +0,0 @@ -specVersion: "5.0" -metadata: - name: application.a.ts -type: application -builder: - customTasks: - - name: ui5-tooling-transpile-task - afterTask: replaceVersion -server: - customMiddleware: - - name: ui5-tooling-transpile-middleware - afterMiddleware: compression diff --git a/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts b/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts deleted file mode 100644 index e20a74790f2..00000000000 --- a/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts +++ /dev/null @@ -1,22 +0,0 @@ -type randomTSType = { - first: { - a: number, - b: number, - c: number - }, - second: string -} - -export default class Main { - onInit(): void { - const z : randomTSType = { - first: { - a: 1, - b: 2, - c: 3 - }, - second: "test" - }; - console.log(z.first.a); - } -} diff --git a/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json b/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json deleted file mode 100644 index ae5be89bac7..00000000000 --- a/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "_version": "1.12.0", - "sap.app": { - "id": "application.a.ts", - "type": "application", - "i18n": "i18n/i18n.properties", - "title": "{{appTitle}}", - "description": "{{appDescription}}", - "applicationVersion": { - "version": "1.0.0" - } - }, - "sap.ui": { - "technology": "UI5", - "icons": {}, - "deviceTypes": { - "desktop": true, - "tablet": true, - "phone": true - } - }, - "sap.ui5": { - "rootView": { - "viewName": "application.a.ts.view.App", - "type": "XML", - "async": true, - "id": "app" - }, - "handleValidation": true, - "contentDensities": { - "compact": true, - "cozy": true - }, - "models": { - "i18n": { - "type": "sap.ui.model.resource.ResourceModel", - "settings": { - "bundleName": "application.a.ts.i18n.i18n" - } - } - }, - "routing": { - "config": { - "routerClass": "sap.m.routing.Router", - "viewType": "XML", - "viewPath": "application.a.ts.view", - "controlId": "app", - "controlAggregation": "pages", - "async": true - }, - "routes": [ - { - "pattern": "", - "name": "main", - "target": "main" - } - ], - "targets": { - "main": { - "viewId": "main", - "viewName": "Main" - } - } - } - } -} diff --git a/internal/e2e-tests/fixtures/application.a/package.json b/internal/e2e-tests/fixtures/application.a/package.json deleted file mode 100644 index 4c8a19f1593..00000000000 --- a/internal/e2e-tests/fixtures/application.a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "application.a", - "version": "1.0.0", - "description": "UI5 Application: application.a", - "license": "Apache-2.0", - "dependencies": { - "chart.js": "4.5.1" - }, - "devDependencies": { - "@openui5/types": "1.115.1", - "ui5-task-zipper": "3.6.0", - "ui5-tooling-modules": "3.35.0", - "ui5-tooling-stringreplace": "3.6.0" - } -} diff --git a/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml b/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml deleted file mode 100644 index b217eb59ec7..00000000000 --- a/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml +++ /dev/null @@ -1,10 +0,0 @@ -specVersion: "5.0" -metadata: - name: application.a -type: application -builder: - customTasks: - - name: ui5-task-zipper - afterTask: generateVersionInfo - configuration: - archiveName: "webapp" diff --git a/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml b/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml deleted file mode 100644 index 943ea39c81a..00000000000 --- a/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml +++ /dev/null @@ -1,12 +0,0 @@ -specVersion: "5.0" -metadata: - name: application.a -type: application -server: - customMiddleware: - - name: ui5-tooling-modules-middleware - afterMiddleware: compression -builder: - customTasks: - - name: ui5-tooling-modules-task - afterTask: replaceVersion diff --git a/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml b/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml deleted file mode 100644 index 36fad93ceee..00000000000 --- a/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml +++ /dev/null @@ -1,14 +0,0 @@ -specVersion: "5.0" -metadata: - name: application.a -type: application -builder: - customTasks: - - name: ui5-tooling-stringreplace-task - afterTask: replaceVersion - configuration: - files: - - "**/*.js" - replace: - - placeholder: ${PLACEHOLDER_TEXT} - value: "'INSERTED_TEXT'" diff --git a/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js deleted file mode 100644 index 8b0befdce30..00000000000 --- a/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js +++ /dev/null @@ -1,16 +0,0 @@ -sap.ui.define([], () => { - return Controller.extend("application.a.controller.Test",{ - onInit() { - const z = { - first: { - a: 1, - b: 2, - c: 3 - }, - second: "test" - }; - console.log(z.first.a); - } - }); -}); - diff --git a/internal/e2e-tests/fixtures/application.a/webapp/manifest.json b/internal/e2e-tests/fixtures/application.a/webapp/manifest.json deleted file mode 100644 index 58eb693c97f..00000000000 --- a/internal/e2e-tests/fixtures/application.a/webapp/manifest.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "_version": "1.12.0", - "sap.app": { - "id": "application.a", - "type": "application", - "i18n": "i18n/i18n.properties", - "title": "{{appTitle}}", - "description": "{{appDescription}}", - "applicationVersion": { - "version": "1.0.0" - } - }, - "sap.ui": { - "technology": "UI5", - "icons": {}, - "deviceTypes": { - "desktop": true, - "tablet": true, - "phone": true - } - }, - "sap.ui5": { - "rootView": { - "viewName": "application.a.view.App", - "type": "XML", - "async": true, - "id": "app" - }, - "handleValidation": true, - "contentDensities": { - "compact": true, - "cozy": true - }, - "models": { - "i18n": { - "type": "sap.ui.model.resource.ResourceModel", - "settings": { - "bundleName": "application.a.i18n.i18n" - } - } - }, - "routing": { - "config": { - "routerClass": "sap.m.routing.Router", - "viewType": "XML", - "viewPath": "application.a.view", - "controlId": "app", - "controlAggregation": "pages", - "async": true - }, - "routes": [ - { - "pattern": "", - "name": "main", - "target": "main" - } - ], - "targets": { - "main": { - "viewId": "main", - "viewName": "Main" - } - } - } - } -} diff --git a/internal/e2e-tests/package.json b/internal/e2e-tests/package.json deleted file mode 100644 index 5e942dc30b4..00000000000 --- a/internal/e2e-tests/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@ui5-internal/e2e-tests", - "private": true, - "license": "Apache-2.0", - "type": "module", - "engines": { - "node": "^22.20.0 || >=24.0.0", - "npm": ">= 8" - }, - "scripts": { - "test": "npm run lint && npm run coverage", - "unit": "node --test 'test/**/*.js'", - "unit-watch": "node --test --watch 'test/**/*.js'", - "coverage": "node --test --experimental-test-coverage 'test/**/*.js'", - "lint": "eslint ." - }, - "devDependencies": { - "eslint": "^9.39.1" - }, - "dependencies": { - "adm-zip": "^0.5.17" - } -} diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js deleted file mode 100644 index ed90436cdfa..00000000000 --- a/internal/e2e-tests/test/build.js +++ /dev/null @@ -1,228 +0,0 @@ -// Test running "ui5 build" -// with fixtures (under ../fixtures) -// and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. - -import { execFile } from "node:child_process"; -import {test, describe} from "node:test"; -import {fileURLToPath} from "node:url"; -import path from "node:path"; -import fs from "node:fs/promises"; -import AdmZip from "adm-zip"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const ui5CliPath = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); - -class FixtureHelper { - constructor(fixtureName) { - this.fixtureName = fixtureName; - this.originFixturePath = path.resolve(__dirname, "../fixtures", fixtureName); - this.tmpPath = path.resolve(__dirname, "../tmp", fixtureName); - this.dotUi5Path = path.resolve(this.tmpPath, ".ui5"); - this.distPath = path.resolve(this.tmpPath, "dist"); - } - - async init() { - // Clean up previous runs - await fs.rm(this.tmpPath, {recursive: true, force: true}); - // Copy source files to temp location - await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); - // Install node_modules - await this._installNodeModules(); - } - - async prepareForNextRun() { - // Delete everything from the tmp/ folder except .ui5 & dist folders - const entries = await fs.readdir(this.tmpPath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name === ".ui5" || entry.name === "dist" || entry.name === "node_modules") { - continue; - } - const entryPath = path.resolve(this.tmpPath, entry.name); - await fs.rm(entryPath, { recursive: true, force: true }); - } - // Copy source files to temp location - await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); - } - - async build(assert, ui5YamlName) { - await new Promise((resolve, reject) => { - execFile("node", [ui5CliPath, "build", "--config", ui5YamlName, "--dest", this.distPath], async (error, stdout, stderr) => { - if (error) { - assert.fail(error); - reject(error); - return; - } - resolve(); - }); - }); - } - - async _installNodeModules() { - await new Promise((resolve, reject) => { - execFile("npm", ["install"], { cwd: this.tmpPath }, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -describe("ui5 build", () => { - test("ui5-tooling-transpile", async ({assert}) => { - const fixtureHelper = new FixtureHelper("application.a.ts"); - await fixtureHelper.init(); - process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; - process.chdir(fixtureHelper.tmpPath); - const ui5YamlName = "ui5-tooling-transpile.yaml"; - - // #1 Build - await fixtureHelper.build(assert, ui5YamlName); - - // Test: no TS syntax is left in the preload (transpile + preload tasks succeeeded) - const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); - assert.ok(componentPreload.includes("application/a/ts/controller/Test.controller"), "Component-preload.js should contain the TS resource transpiled to JS"); - assert.ok(!componentPreload.includes("randomTSType"), "Component-preload.js should NOT contain any TS syntax"); - const componentPreloadMap = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js.map"), "utf-8"); - assert.ok(componentPreloadMap.includes("randomTSType"), "Component-preload.js.map should contain the TS type information"); - - // -------------------------------------------------------------------------------------------- - - // Modify source files - await fixtureHelper.prepareForNextRun(); - const fileToModify = path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.ts"); - const fileContent = await fs.readFile(fileToModify, "utf-8"); - const modifiedContent = fileContent.replace("second: \"test\"", "second: \"test_2\""); - await fs.writeFile(fileToModify, modifiedContent, "utf-8"); - - // #2 Build - await fixtureHelper.build(assert, ui5YamlName); - - // Test: the modified content is reflected in the new build output (transpile + preload tasks succeeeded) - const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); - assert.ok(newComponentPreload.includes("second:\"test_2\""), "Component-preload.js should contain the updated content from the modified source file"); - }); - - test("ui5-task-zipper", async ({assert}) => { - const fixtureHelper = new FixtureHelper("application.a"); - await fixtureHelper.init(); - process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; - process.chdir(fixtureHelper.tmpPath); - const ui5YamlName = "ui5-task-zipper.yaml"; - - // #1 Build - await fixtureHelper.build(assert, ui5YamlName); - - // Test: the zip file is created in the dist folder - const zipFilePath = path.resolve(fixtureHelper.distPath, "webapp.zip"); - const zipFileExists = await fs.access(zipFilePath).then(() => true).catch(() => false); - assert.ok(zipFileExists, "The zip file should be created in the dist folder"); - - // Check the archive content - const zip = new AdmZip(zipFilePath); - const zipEntries = zip.getEntries(); - assert.ok(zipEntries.length > 0, "The zip file should contain entries"); - - // Check that the zip file contains the expected source file - const testControllerEntry = zipEntries.find(entry => entry.entryName === "controller/Test.controller.js"); - assert.ok(testControllerEntry, "The zip file should contain the expected source file"); - - // -------------------------------------------------------------------------------------------- - - // Delete a source file - await fixtureHelper.prepareForNextRun(); - await fs.rm(path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.js")); - - // #2 Build - await fixtureHelper.build(assert, ui5YamlName); - - // Test: the zip file is updated and does not contain the deleted file - const newZipFileExists = await fs.access(zipFilePath).then(() => true).catch(() => false); - assert.ok(newZipFileExists, "The zip file should be created in the dist folder after the second build"); - - // Check the archive content - const zip2 = new AdmZip(zipFilePath); - const zipEntries2 = zip2.getEntries(); - assert.ok(zipEntries2.length > 0, "The zip file should contain entries after the second build"); - - // Check that the zip file does NOT contain the expected source file anymore - const deletedTestControllerEntry = zipEntries2.find(entry => entry.entryName === "controller/Test.controller.js"); - assert.ok(!deletedTestControllerEntry, "The zip file should NOT contain the deleted source file"); - }); - - test("ui5-tooling-modules", async ({assert}) => { - const fixtureHelper = new FixtureHelper("application.a"); - await fixtureHelper.init(); - process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; - process.chdir(fixtureHelper.tmpPath); - const ui5YamlName = "ui5-tooling-modules.yaml"; - - // #1 Build (no thirdparty module yet -> just checking that the build succeeds) - await fixtureHelper.build(assert, ui5YamlName); - - // -------------------------------------------------------------------------------------------- - - // Add a new source file with a third party import - await fixtureHelper.prepareForNextRun(); - const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); - const newControllerContent = -`sap.ui.define(["chart.js"], (chartJS) => { - return Controller.extend("application.a.controller.New",{ - onInit() { - console.log(chartJS); - } - }); -});`; - await fs.writeFile(newControllerPath, newControllerContent, "utf-8"); - - // #2 Build - await fixtureHelper.build(assert, ui5YamlName); - - // Test: the dist contains the new controller and the third party import - const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); - assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/New.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'New' controller and chart.js"); - }); - - // FIXME: Currently failing - test("ui5-tooling-stringreplace", async ({assert}) => { - const fixtureHelper = new FixtureHelper("application.a"); - await fixtureHelper.init(); - process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; - process.chdir(fixtureHelper.tmpPath); - const ui5YamlName = "ui5-tooling-stringreplace.yaml"; - - // #1 Build (no string replacing yet -> just checking that the build succeeds) - await fixtureHelper.build(assert, ui5YamlName); - - // -------------------------------------------------------------------------------------------- - - - // Add a new source file with a placeholder string - await fixtureHelper.prepareForNextRun(); - const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); - const newControllerContent = -`sap.ui.define([], () => { - return Controller.extend("application.a.controller.New",{ - onInit() { - console.log(\${PLACEHOLDER_TEXT}); - } - }); -});`; - await fs.writeFile(newControllerPath, newControllerContent, "utf-8"); - - // #2 Build - // FIXME: Currently failing here (April 02 2026) - // Error message: - // ("Minification failed with error: Unexpected token punc «{», expected punc «,» in file /resources/application/a/controller/New.controller.js (line 4, col 16, pos 114)") - // - // -> Probably, the string replacement doesn't get executed as very first middleware (minify happens earlier unexpectedly) - await fixtureHelper.build(assert, ui5YamlName); - - // Test: the placeholder in the source file is replaced in the dist output - const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); - assert.ok(componentPreload.includes("console.log(\"INSERTED_TEXT\")"), "The placeholder should get replaced with the expected text in the component preload"); - }); -}); diff --git a/internal/e2e-tests/test/version.js b/internal/e2e-tests/test/version.js deleted file mode 100644 index 30c2eb9e22e..00000000000 --- a/internal/e2e-tests/test/version.js +++ /dev/null @@ -1,31 +0,0 @@ -// Test running "ui5 --version" -// without any fixtures or additional setup. -// and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. - -import { execFile } from "node:child_process"; -import {test, describe} from "node:test"; -import {fileURLToPath} from "node:url"; -import path from "node:path"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("ui5 version", () => { - test("output the version of the UI5 CLI", ({assert}) => { - const ui5Path = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); - execFile("node", [ui5Path, "--version"], (error, stdout, stderr) => { - if (error) { - assert.fail(error); - return; - } - if (stderr) { - assert.fail(new Error(stderr)); - return; - } - // Test: the expected CLI version output is printed - // e.g. "5.0.0 (from /path/to/ui5/cli)" - const outPattern = /\d+\..* \(from .*\)/; - assert.ok(stdout.match(outPattern)); - }); - }); -}); diff --git a/package-lock.json b/package-lock.json index 40174916c21..90009c87421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,20 +75,6 @@ "npm": ">= 8" } }, - "internal/e2e-tests": { - "name": "@ui5-internal/e2e-tests", - "license": "Apache-2.0", - "dependencies": { - "adm-zip": "^0.5.17" - }, - "devDependencies": { - "eslint": "^9.39.1" - }, - "engines": { - "node": "^22.20.0 || >=24.0.0", - "npm": ">= 8" - } - }, "internal/shrinkwrap-extractor": { "name": "@ui5/shrinkwrap-extractor", "version": "1.0.0", @@ -4276,10 +4262,6 @@ "resolved": "internal/benchmark", "link": true }, - "node_modules/@ui5-internal/e2e-tests": { - "resolved": "internal/e2e-tests", - "link": true - }, "node_modules/@ui5/builder": { "resolved": "packages/builder", "link": true @@ -4637,15 +4619,6 @@ "node": ">=0.4.0" } }, - "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "license": "MIT", @@ -4786,8 +4759,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4799,8 +4770,6 @@ }, "node_modules/anymatch/node_modules/picomatch": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5139,8 +5108,6 @@ }, "node_modules/binary-extensions": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "license": "MIT", "engines": { "node": ">=8" @@ -5663,8 +5630,6 @@ }, "node_modules/chokidar": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5687,8 +5652,6 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -9260,8 +9223,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -11120,8 +11081,6 @@ }, "node_modules/normalize-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12970,8 +12929,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -12982,8 +12939,6 @@ }, "node_modules/readdirp/node_modules/picomatch": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" From 9e618abe3d48cd1a6ac20f13b162fb5ec66d1a44 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 16 Apr 2026 18:36:22 +0200 Subject: [PATCH 196/223] perf(project): Parallelize I/O in HashTree.upsertResources and TreeRegistry.flush Split upsertResources into two phases: concurrent I/O resolution via Promise.all (getIntegrity, getSize, matchResourceMetadataStrict) followed by serial tree mutation. This avoids sequential file reads when processing many resources. Pre-resolve getIntegrity and getSize for all pending resources concurrently via Promise.all before the serial tree-mutation loop. This avoids redundant sequential I/O across the triple-nested directory x tree x resource loop. --- .../project/lib/build/cache/index/HashTree.js | 152 +++++++++++++----- .../lib/build/cache/index/TreeRegistry.js | 50 +++++- 2 files changed, 161 insertions(+), 41 deletions(-) diff --git a/packages/project/lib/build/cache/index/HashTree.js b/packages/project/lib/build/cache/index/HashTree.js index 88ac63b48c6..182472e5e61 100644 --- a/packages/project/lib/build/cache/index/HashTree.js +++ b/packages/project/lib/build/cache/index/HashTree.js @@ -352,39 +352,74 @@ export default class HashTree { return {added: [], updated: [], unchanged: []}; } - // Immediate mode const added = []; const updated = []; const unchanged = []; const affectedPaths = new Set(); + // Phase 1: Filter out clearly-unchanged resources using cheap sync checks. + // This avoids async/Promise overhead and unnecessary I/O for the common case + // where most resources haven't changed (lastModified short-circuit). + const needsIO = []; + for (const resource of resources) { const resourcePath = resource.getOriginalPath(); const existingNode = this.getResourceByPath(resourcePath); if (!existingNode) { - // Insert new resource - const resourceData = { - integrity: await resource.getIntegrity(), - lastModified: resource.getLastModified(), - size: await resource.getSize(), - inode: resource.getInode(), - tags: resource.getTags() - }; - this._insertResource(resourcePath, resourceData); + // New resource — always needs I/O + needsIO.push({resource, resourcePath, existingNode: null, isNew: true}); + continue; + } - const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); - const resourceNode = this._findNode(resourcePath); - this._computeHash(resourceNode); + // Replicate matchResourceMetadataStrict's fast path (sync, no I/O): + // If lastModified matches and is not at risk of race condition, content is unchanged. + const currentLastModified = resource.getLastModified(); + if (currentLastModified === existingNode.lastModified && + this.#indexTimestamp && currentLastModified !== this.#indexTimestamp) { + // Content definitely unchanged — check tags + if (tagsEqual(existingNode.tags, resource.getTags())) { + unchanged.push(resourcePath); + } else { + // Tag-only change — update tags without I/O + existingNode.tags = resource.getTags(); + this._computeHash(existingNode); + updated.push(resourcePath); + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + for (let i = 0; i < parts.length; i++) { + affectedPaths.add(parts.slice(0, i).join(path.sep)); + } + } + continue; + } - added.push(resourcePath); + // Potentially changed — needs I/O to determine + needsIO.push({resource, resourcePath, existingNode, isNew: false}); + } - // Mark ancestors for recomputation - for (let i = 0; i < parts.length; i++) { - affectedPaths.add(parts.slice(0, i).join(path.sep)); + // Phase 2: Resolve I/O concurrently for resources that need it. + // Only new or potentially-changed resources reach this point. + if (needsIO.length > 0) { + const preResolved = await Promise.all(needsIO.map(async ({resource, resourcePath, existingNode, isNew}) => { + if (isNew) { + const [integrity, size] = await Promise.all([ + resource.getIntegrity(), + resource.getSize() + ]); + return { + resourcePath, + existingNode: null, + isNew: true, + integrity, + size, + lastModified: resource.getLastModified(), + inode: resource.getInode(), + tags: resource.getTags() + }; } - } else { - // Check if unchanged + + // Existing resource with potential change — use matchResourceMetadataStrict + // for the remaining checks (size comparison, integrity comparison) const currentMetadata = { integrity: existingNode.integrity, lastModified: existingNode.lastModified, @@ -392,28 +427,73 @@ export default class HashTree { inode: existingNode.inode }; - const isUnchanged = await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); + const isUnchanged = + await matchResourceMetadataStrict(resource, currentMetadata, this.#indexTimestamp); + if (isUnchanged) { - const currentTags = resource.getTags(); - if (tagsEqual(existingNode.tags, currentTags)) { - unchanged.push(resourcePath); - continue; - } - // Tags changed — fall through to update + return {resourcePath, existingNode, isUnchanged: true, tags: resource.getTags()}; } - // Update existing resource - existingNode.integrity = await resource.getIntegrity(); - existingNode.lastModified = resource.getLastModified(); - existingNode.size = await resource.getSize(); - existingNode.inode = resource.getInode(); - existingNode.tags = resource.getTags(); + // Changed — integrity/size are cached in the Resource from matchResourceMetadataStrict + const [integrity, size] = await Promise.all([ + resource.getIntegrity(), + resource.getSize() + ]); + return { + resourcePath, + existingNode, + isNew: false, + isUnchanged: false, + integrity, + size, + lastModified: resource.getLastModified(), + inode: resource.getInode(), + tags: resource.getTags() + }; + })); + + // Phase 3: Apply resolved results to the tree + for (const resolved of preResolved) { + if (resolved.isUnchanged) { + if (tagsEqual(resolved.existingNode.tags, resolved.tags)) { + unchanged.push(resolved.resourcePath); + } else { + resolved.existingNode.tags = resolved.tags; + this._computeHash(resolved.existingNode); + updated.push(resolved.resourcePath); + const parts = resolved.resourcePath.split(path.sep).filter((p) => p.length > 0); + for (let i = 0; i < parts.length; i++) { + affectedPaths.add(parts.slice(0, i).join(path.sep)); + } + } + continue; + } - this._computeHash(existingNode); - updated.push(resourcePath); + const parts = resolved.resourcePath.split(path.sep).filter((p) => p.length > 0); + + if (resolved.isNew) { + this._insertResource(resolved.resourcePath, { + integrity: resolved.integrity, + lastModified: resolved.lastModified, + size: resolved.size, + inode: resolved.inode, + tags: resolved.tags + }); + + const resourceNode = this._findNode(resolved.resourcePath); + this._computeHash(resourceNode); + added.push(resolved.resourcePath); + } else { + resolved.existingNode.integrity = resolved.integrity; + resolved.existingNode.lastModified = resolved.lastModified; + resolved.existingNode.size = resolved.size; + resolved.existingNode.inode = resolved.inode; + resolved.existingNode.tags = resolved.tags; + + this._computeHash(resolved.existingNode); + updated.push(resolved.resourcePath); + } - // Mark ancestors for recomputation - const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); for (let i = 0; i < parts.length; i++) { affectedPaths.add(parts.slice(0, i).join(path.sep)); } diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 79fd35e99e4..5a2d81cea82 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -279,6 +279,43 @@ export default class TreeRegistry { upsertsByDir.get(parentPath).push({resourceName, resource, fullPath: resourcePath, sourceTree}); } + // Pre-resolve I/O for resources that don't exist in any tree (genuinely new). + // For existing resources, matchResourceMetadataStrict's lastModified short-circuit + // avoids I/O in the common case, so those are handled serially in the loop below. + const resolvedNewMetadata = new Map(); + const newResourcePaths = []; + for (const [resourcePath, {resource}] of this.pendingUpserts) { + let existsInAnyTree = false; + const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); + const resourceName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join(path.sep); + for (const tree of this.trees) { + const parentNode = tree._findNode(parentPath); + if (parentNode?.children?.get(resourceName)) { + existsInAnyTree = true; + break; + } + } + if (!existsInAnyTree) { + newResourcePaths.push({resourcePath, resource}); + } + } + if (newResourcePaths.length > 0) { + await Promise.all(newResourcePaths.map(async ({resourcePath, resource}) => { + const [integrity, size] = await Promise.all([ + resource.getIntegrity(), + resource.getSize() + ]); + resolvedNewMetadata.set(resourcePath, { + integrity, + size, + lastModified: resource.getLastModified(), + inode: resource.getInode(), + tags: resource.getTags?.() ?? resource.tags ?? null + }); + })); + } + // Apply upserts for (const [parentPath, upserts] of upsertsByDir) { for (const tree of this.trees) { @@ -310,13 +347,16 @@ export default class TreeRegistry { continue; } + // Use pre-resolved metadata if available, otherwise resolve now + const resolved = resolvedNewMetadata.get(upsert.fullPath); + // Create new resource node resourceNode = new TreeNode(upsert.resourceName, "resource", { - integrity: await upsert.resource.getIntegrity(), - lastModified: upsert.resource.getLastModified(), - size: await upsert.resource.getSize(), - inode: upsert.resource.getInode(), - tags: upsert.resource.getTags?.() ?? upsert.resource.tags ?? null + integrity: resolved?.integrity ?? await upsert.resource.getIntegrity(), + lastModified: resolved?.lastModified ?? upsert.resource.getLastModified(), + size: resolved?.size ?? await upsert.resource.getSize(), + inode: resolved?.inode ?? upsert.resource.getInode(), + tags: resolved?.tags ?? upsert.resource.getTags?.() ?? upsert.resource.tags ?? null }); parentNode.children.set(upsert.resourceName, resourceNode); modifiedNodes.add(resourceNode); From 6c10ed8ba844eebf76dbb35cd829fed451fc5f6d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 12:14:00 +0200 Subject: [PATCH 197/223] perf(project): Parallelize I/O in ResourceRequestManager Restructure updateIndices() into 3 phases: collect all unique paths across request sets, batch-fetch in parallel via Promise.all, then process synchronously from cache. Also parallelize refreshIndices() node processing since SharedHashTree operations schedule atomically into the TreeRegistry. --- .../lib/build/cache/ResourceRequestManager.js | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index cc1872623d9..e84b73a0bba 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -143,7 +143,9 @@ class ResourceRequestManager { } const resourceCache = new Map(); - for (const {nodeId} of this.#requestGraph.traverseByDepth()) { + const nodeEntries = Array.from(this.#requestGraph.traverseByDepth()); + + await Promise.all(nodeEntries.map(async ({nodeId}) => { const {resourceIndex} = this.#requestGraph.getMetadata(nodeId); if (!resourceIndex) { throw new Error(`Missing resource index for request set ID ${nodeId}`); @@ -163,7 +165,7 @@ class ResourceRequestManager { if (resourcesToUpdate.length) { await resourceIndex.upsertResources(resourcesToUpdate); } - } + })); await this.#flushTreeChangesWithoutDiffTracking(); } @@ -214,7 +216,27 @@ class ResourceRequestManager { } const resourceCache = new Map(); - // Update matching resource indices + + // Phase 1: Collect all unique paths to fetch + const allPathsToFetch = new Set(); + for (const requestSetId of matchingRequestSetIds) { + const resourcePathsToUpdate = updatesByRequestSetId.get(requestSetId); + for (const resourcePath of resourcePathsToUpdate) { + if (!resourceCache.has(resourcePath)) { + allPathsToFetch.add(resourcePath); + } + } + } + + // Phase 2: Batch-fetch in parallel + if (allPathsToFetch.size > 0) { + await Promise.all(Array.from(allPathsToFetch).map(async (resourcePath) => { + const resource = await reader.byPath(resourcePath); + resourceCache.set(resourcePath, resource ?? null); + })); + } + + // Phase 3: Process each request set from cache for (const requestSetId of matchingRequestSetIds) { const {resourceIndex} = this.#requestGraph.getMetadata(requestSetId); if (!resourceIndex) { @@ -225,17 +247,10 @@ class ResourceRequestManager { const resourcesToUpdate = []; const removedResourcePaths = []; for (const resourcePath of resourcePathsToUpdate) { - let resource; - if (resourceCache.has(resourcePath)) { - resource = resourceCache.get(resourcePath); - } else { - resource = await reader.byPath(resourcePath); - resourceCache.set(resourcePath, resource); - } + const resource = resourceCache.get(resourcePath); if (resource) { resourcesToUpdate.push(resource); } else { - // Resource has been removed removedResourcePaths.push(resourcePath); } } From 43cb20b497ac34e0b2cf6a62a59387f4e04b3c32 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 12:14:38 +0200 Subject: [PATCH 198/223] perf(project): Skip redundant metadata checks for shared tree nodes Add unchangedNodes Set in TreeRegistry.flush() to track resource nodes already confirmed unchanged via matchResourceMetadataStrict. Subsequent trees sharing the same node skip the full comparison and only check tags, eliminating N-1 async calls per shared resource across N trees. --- .../lib/build/cache/index/TreeRegistry.js | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 5a2d81cea82..26e6efd4430 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -202,6 +202,8 @@ export default class TreeRegistry { // Track which resource nodes we've already modified to handle shared nodes const modifiedNodes = new Set(); + // Track which resource nodes we've already confirmed unchanged to skip redundant checks + const unchangedNodes = new Set(); // Track all affected trees and the paths that need recomputation const affectedTrees = new Map(); // tree -> Set of directory paths needing recomputation @@ -370,7 +372,38 @@ export default class TreeRegistry { } } else if (resourceNode.type === "resource") { // UPDATE: Check if modified - if (!modifiedNodes.has(resourceNode)) { + if (modifiedNodes.has(resourceNode)) { + // Node was already modified by another tree (shared node) + // Still count it as an update for this tree since the change affects it + treeStats.get(tree).updated.push(upsert.fullPath); + dirModified = true; + } else if (unchangedNodes.has(resourceNode)) { + // Already confirmed unchanged in another tree — just check tags + const currentTags = + upsert.resource.getTags?.() ?? upsert.resource.tags ?? null; + if (!tagsEqual(resourceNode.tags, currentTags)) { + // Tags changed — treat as update + resourceNode.tags = currentTags; + modifiedNodes.add(resourceNode); + unchangedNodes.delete(resourceNode); + dirModified = true; + + // Track per-tree update + treeStats.get(tree).updated.push(upsert.fullPath); + + if (!updatedResources.includes(upsert.fullPath)) { + updatedResources.push(upsert.fullPath); + } + } else { + // Track per-tree unchanged + treeStats.get(tree).unchanged.push(upsert.fullPath); + + if (!unchangedResources.includes(upsert.fullPath)) { + unchangedResources.push(upsert.fullPath); + } + } + } else { + // First time seeing this node — do full comparison const currentMetadata = { integrity: resourceNode.integrity, lastModified: resourceNode.lastModified, @@ -401,11 +434,14 @@ export default class TreeRegistry { updatedResources.push(upsert.fullPath); } } else { - const currentTags = upsert.resource.getTags?.() ?? upsert.resource.tags ?? null; + unchangedNodes.add(resourceNode); + const currentTags = + upsert.resource.getTags?.() ?? upsert.resource.tags ?? null; if (!tagsEqual(resourceNode.tags, currentTags)) { // Tags changed — treat as update resourceNode.tags = currentTags; modifiedNodes.add(resourceNode); + unchangedNodes.delete(resourceNode); dirModified = true; // Track per-tree update @@ -423,11 +459,6 @@ export default class TreeRegistry { } } } - } else { - // Node was already modified by another tree (shared node) - // Still count it as an update for this tree since the change affects it - treeStats.get(tree).updated.push(upsert.fullPath); - dirModified = true; } } } From 368b0dcc1ce5f26c6fbd60144e5de48c66094d45 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 12:21:31 +0200 Subject: [PATCH 199/223] perf(project): Prefetch stage cache lookups for next task Overlap stage cache I/O by prefetching metadata for the next task while the current task is executing. This reduces idle time between sequential task executions in warm-cache builds. --- packages/project/lib/build/TaskRunner.js | 6 +- .../lib/build/cache/ProjectBuildCache.js | 152 ++++++++++++++---- packages/project/test/lib/build/TaskRunner.js | 1 + 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index dff391631ce..b24aed34e25 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -133,10 +133,14 @@ class TaskRunner { this._log.setTasks(allTasks); this._buildCache.setTasks(allTasks); - for (const taskName of allTasks) { + for (let i = 0; i < allTasks.length; i++) { signal?.throwIfAborted(); + const taskName = allTasks[i]; const taskFunction = this._tasks[taskName].task; + if (i + 1 < allTasks.length) { + this._buildCache.prefetchStageCache(allTasks[i + 1]); + } if (typeof taskFunction === "function") { await this._executeTask(taskName, taskFunction); } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 9cd7b36fb4b..1ad09cfb851 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -45,6 +45,7 @@ export const RESULT_CACHE_STATES = Object.freeze({ export default class ProjectBuildCache { #taskCache = new Map(); #stageCache = new StageCache(); + #prefetchedStageReads; #project; #buildSignature; @@ -516,6 +517,52 @@ export default class ProjectBuildCache { return false; // Task needs to be executed } + /** + * Pre-fetches stage cache metadata from persistent storage for the given task. + * Results are stored internally and consumed by #findStageCache when called later. + * + * @public + * @param {string} taskName Task name to prefetch cache for + */ + prefetchStageCache(taskName) { + const taskCache = this.#taskCache.get(taskName); + if (!taskCache) { + return; + } + const stageName = this.#getStageNameForTask(taskName); + + // Compute possible signatures from current index state + const projectSignatures = taskCache.getProjectIndexSignatures(); + const dependencySignatures = taskCache.getDependencyIndexSignatures(); + const stageSignatures = combineTwoArraysFast( + projectSignatures, + dependencySignatures, + ).map((signaturePair) => { + return createStageSignature(...signaturePair); + }); + + if (!stageSignatures.length) { + return; + } + + // Filter out signatures already in memory + const uncachedSignatures = stageSignatures.filter((sig) => + !this.#stageCache.getCacheForSignature(stageName, sig)); + + if (!uncachedSignatures.length) { + return; + } + + // Start reading from disk — store promises, don't await + const prefetchMap = new Map(); + for (const sig of uncachedSignatures) { + prefetchMap.set(sig, this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, stageName, sig)); + } + this.#prefetchedStageReads = this.#prefetchedStageReads ?? new Map(); + this.#prefetchedStageReads.set(stageName, prefetchMap); + } + /** * Attempts to find a cached stage for the given task * @@ -540,6 +587,29 @@ export default class ProjectBuildCache { return stageCache; } } + + // Check prefetched data + const prefetchMap = this.#prefetchedStageReads?.get(stageName); + if (prefetchMap) { + this.#prefetchedStageReads.delete(stageName); + for (const stageSignature of stageSignatures) { + const promise = prefetchMap.get(stageSignature); + if (promise) { + const stageMetadata = await promise; + if (stageMetadata) { + log.verbose(`Found prefetched cached stage for task ${stageName} ` + + `with signature ${stageSignature}`); + return this.#processStageCacheMetadata(stageName, stageSignature, stageMetadata); + } + } + } + // Filter out already-checked signatures from disk lookup + stageSignatures = stageSignatures.filter((sig) => !prefetchMap.has(sig)); + if (!stageSignatures.length) { + return; + } + } + // TODO: If list of signatures is longer than N, // retrieve all available signatures from cache manager first. // Later maybe add a bloom filter for even larger sets @@ -550,45 +620,57 @@ export default class ProjectBuildCache { return; } log.verbose(`Found cached stage for task ${stageName} with signature ${stageSignature}`); - const {resourceMapping, resourceMetadata, projectTagOperations, buildTagOperations} = stageMetadata; - let writtenResourcePaths; - let stageReader; - if (resourceMapping) { - writtenResourcePaths = []; - // Restore writer collection - const readers = resourceMetadata.map((metadata) => { - writtenResourcePaths.push(...Object.keys(metadata)); - return this.#createReaderForStageCache( - stageName, stageSignature, metadata); - }); + return this.#processStageCacheMetadata(stageName, stageSignature, stageMetadata); + })); + return stageCache; + } - const writerMapping = Object.create(null); - for (const [resourcePath, metadataIndex] of Object.entries(resourceMapping)) { - if (!readers[metadataIndex]) { - throw new Error(`Inconsistent stage cache: No resource metadata ` + - `found at index ${metadataIndex} for resource ${resourcePath}`); - } - writerMapping[resourcePath] = readers[metadataIndex]; - } + /** + * Processes stage cache metadata into a stage cache entry + * + * @param {string} stageName Name of the stage + * @param {string} stageSignature Signature of the stage + * @param {object} stageMetadata Raw metadata from cache + * @returns {object} Stage cache entry + */ + #processStageCacheMetadata(stageName, stageSignature, stageMetadata) { + const {resourceMapping, resourceMetadata, projectTagOperations, buildTagOperations} = stageMetadata; + let writtenResourcePaths; + let stageReader; + if (resourceMapping) { + writtenResourcePaths = []; + // Restore writer collection + const readers = resourceMetadata.map((metadata) => { + writtenResourcePaths.push(...Object.keys(metadata)); + return this.#createReaderForStageCache( + stageName, stageSignature, metadata); + }); - stageReader = createWriterCollection({ - name: `Restored cached stage ${stageName} for project ${this.#project.getName()}`, - writerMapping, - }); - } else { - writtenResourcePaths = Object.keys(resourceMetadata); - stageReader = this.#createReaderForStageCache(stageName, stageSignature, resourceMetadata); + const writerMapping = Object.create(null); + for (const [resourcePath, metadataIndex] of Object.entries(resourceMapping)) { + if (!readers[metadataIndex]) { + throw new Error(`Inconsistent stage cache: No resource metadata ` + + `found at index ${metadataIndex} for resource ${resourcePath}`); + } + writerMapping[resourcePath] = readers[metadataIndex]; } - return { - signature: stageSignature, - stage: stageReader, - writtenResourcePaths, - projectTagOperations: tagOpsToMap(projectTagOperations), - buildTagOperations: tagOpsToMap(buildTagOperations), - }; - })); - return stageCache; + stageReader = createWriterCollection({ + name: `Restored cached stage ${stageName} for project ${this.#project.getName()}`, + writerMapping, + }); + } else { + writtenResourcePaths = Object.keys(resourceMetadata); + stageReader = this.#createReaderForStageCache(stageName, stageSignature, resourceMetadata); + } + + return { + signature: stageSignature, + stage: stageReader, + writtenResourcePaths, + projectTagOperations: tagOpsToMap(projectTagOperations), + buildTagOperations: tagOpsToMap(buildTagOperations), + }; } /** diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index 8fd3f9addc2..3c5ad5e7765 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -131,6 +131,7 @@ test.beforeEach(async (t) => { prepareTaskExecutionAndValidateCache: sinon.stub().resolves(false), recordTaskResult: sinon.stub().resolves(), allTasksCompleted: sinon.stub().resolves([]), + prefetchStageCache: sinon.stub(), }; t.context.resourceFactory = { From 2e787f728dc520d7b8cb539f42aba37ebbb1bee3 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 12:25:15 +0200 Subject: [PATCH 200/223] perf(project): Add performance instrumentation to build cache Add structured timing and counters to identify I/O hotspots in the build cache validation flow. Instrumentation covers refreshIndices, updateIndices, flushTreeChanges, TreeRegistry.flush phases, and matchResourceMetadataStrict. All gated behind @ui5/logger perf level or UI5_CACHE_PERF env var. --- .../lib/build/cache/ProjectBuildCache.js | 13 +++++++ .../lib/build/cache/ResourceRequestManager.js | 34 ++++++++++++++++++- .../lib/build/cache/index/TreeRegistry.js | 26 ++++++++++++++ packages/project/lib/build/cache/utils.js | 13 +++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 1ad09cfb851..821982710f7 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -283,10 +283,23 @@ export default class ProjectBuildCache { log.verbose(`Found result cache with signature ${resultSignature}`); const {stageSignatures, sourceStageSignature} = resultMetadata; + const importStagesStart = log.isLevelEnabled("perf") ? performance.now() : 0; const writtenResourcePaths = await this.#importStages(stageSignatures); + if (log.isLevelEnabled("perf")) { + log.perf( + `#findResultCache importStages for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - importStagesStart).toFixed(2)} ms ` + + `with ${Object.keys(stageSignatures).length} stages`); + } // Restore CAS-backed source reader from the stored source stage + const restoreSourcesStart = log.isLevelEnabled("perf") ? performance.now() : 0; await this.#restoreFrozenSources(sourceStageSignature); + if (log.isLevelEnabled("perf")) { + log.perf( + `#findResultCache restoreFrozenSources for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - restoreSourcesStart).toFixed(2)} ms`); + } log.verbose( `Using cached result stage for project ${this.#project.getName()} with index signature ${resultSignature}`); diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index e84b73a0bba..64d1761ddb3 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -142,6 +142,9 @@ class ResourceRequestManager { return; } + const refreshStart = log.isLevelEnabled("perf") ? performance.now() : 0; + let totalResourcesFetched = 0; + let totalResourcesRemoved = 0; const resourceCache = new Map(); const nodeEntries = Array.from(this.#requestGraph.traverseByDepth()); @@ -159,13 +162,21 @@ class ResourceRequestManager { const resourcesToRemove = indexedResourcePaths.filter((resPath) => { return !currentResourcePaths.includes(resPath); }); + totalResourcesRemoved += resourcesToRemove.length; if (resourcesToRemove.length) { await resourceIndex.removeResources(resourcesToRemove); } + totalResourcesFetched += resourcesToUpdate.length; if (resourcesToUpdate.length) { await resourceIndex.upsertResources(resourcesToUpdate); } })); + if (log.isLevelEnabled("perf")) { + log.perf( + `refreshIndices for task '${this.#taskName}' of project '${this.#projectName}' ` + + `completed in ${(performance.now() - refreshStart).toFixed(2)} ms: ` + + `${totalResourcesFetched} resources fetched, ${totalResourcesRemoved} resources removed`); + } await this.#flushTreeChangesWithoutDiffTracking(); } @@ -217,6 +228,10 @@ class ResourceRequestManager { const resourceCache = new Map(); + const fetchStart = log.isLevelEnabled("perf") ? performance.now() : 0; + let cacheHits = 0; + let cacheMisses = 0; + // Phase 1: Collect all unique paths to fetch const allPathsToFetch = new Set(); for (const requestSetId of matchingRequestSetIds) { @@ -224,6 +239,9 @@ class ResourceRequestManager { for (const resourcePath of resourcePathsToUpdate) { if (!resourceCache.has(resourcePath)) { allPathsToFetch.add(resourcePath); + cacheMisses++; + } else { + cacheHits++; } } } @@ -235,6 +253,12 @@ class ResourceRequestManager { resourceCache.set(resourcePath, resource ?? null); })); } + if (log.isLevelEnabled("perf")) { + log.perf( + `updateIndices for task '${this.#taskName}' of project '${this.#projectName}' ` + + `resource fetch completed in ${(performance.now() - fetchStart).toFixed(2)} ms: ` + + `${cacheHits} cache hits, ${cacheMisses} cache misses`); + } // Phase 3: Process each request set from cache for (const requestSetId of matchingRequestSetIds) { @@ -370,7 +394,15 @@ class ResourceRequestManager { * each containing added, updated, unchanged, and removed resource paths */ async #flushTreeChanges() { - return await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); + const flushStart = log.isLevelEnabled("perf") ? performance.now() : 0; + const results = await Promise.all(this.#treeRegistries.map((registry) => registry.flush())); + if (log.isLevelEnabled("perf")) { + log.perf( + `#flushTreeChanges for task '${this.#taskName}' of project '${this.#projectName}' ` + + `completed in ${(performance.now() - flushStart).toFixed(2)} ms ` + + `across ${this.#treeRegistries.length} registries`); + } + return results; } /** diff --git a/packages/project/lib/build/cache/index/TreeRegistry.js b/packages/project/lib/build/cache/index/TreeRegistry.js index 26e6efd4430..c0ea67ca3d6 100644 --- a/packages/project/lib/build/cache/index/TreeRegistry.js +++ b/packages/project/lib/build/cache/index/TreeRegistry.js @@ -2,6 +2,8 @@ import path from "node:path/posix"; import TreeNode from "./TreeNode.js"; import {tagsEqual} from "./HashTree.js"; import {matchResourceMetadataStrict} from "../utils.js"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:cache:index:TreeRegistry"); /** * Registry for coordinating batch updates across multiple Merkle trees that share nodes by reference. @@ -194,6 +196,9 @@ export default class TreeRegistry { const unchangedResources = []; const removedResources = []; + const perfEnabled = log.isLevelEnabled("perf"); + const phase1Start = perfEnabled ? performance.now() : 0; + // Track per-tree statistics const treeStats = new Map(); for (const tree of this.trees) { @@ -270,6 +275,11 @@ export default class TreeRegistry { // 2. Handle upserts - group by directory const upsertsByDir = new Map(); // parentPath -> [{resourceName, resource, fullPath, sourceTree}] + const phase2Start = perfEnabled ? performance.now() : 0; + let matchMetadataStrictCalls = 0; + let matchMetadataUnchanged = 0; + let modifiedNodesSkips = 0; + for (const [resourcePath, {resource, sourceTree}] of this.pendingUpserts) { const parts = resourcePath.split(path.sep).filter((p) => p.length > 0); const resourceName = parts[parts.length - 1]; @@ -375,6 +385,7 @@ export default class TreeRegistry { if (modifiedNodes.has(resourceNode)) { // Node was already modified by another tree (shared node) // Still count it as an update for this tree since the change affects it + modifiedNodesSkips++; treeStats.get(tree).updated.push(upsert.fullPath); dirModified = true; } else if (unchangedNodes.has(resourceNode)) { @@ -404,6 +415,7 @@ export default class TreeRegistry { } } else { // First time seeing this node — do full comparison + matchMetadataStrictCalls++; const currentMetadata = { integrity: resourceNode.integrity, lastModified: resourceNode.lastModified, @@ -434,6 +446,7 @@ export default class TreeRegistry { updatedResources.push(upsert.fullPath); } } else { + matchMetadataUnchanged++; unchangedNodes.add(resourceNode); const currentTags = upsert.resource.getTags?.() ?? upsert.resource.tags ?? null; @@ -484,6 +497,7 @@ export default class TreeRegistry { } // Recompute ancestor hashes for all affected trees + const phase3Start = perfEnabled ? performance.now() : 0; for (const [tree, affectedPaths] of affectedTrees) { // Sort paths by depth (deepest first) to recompute bottom-up const sortedPaths = Array.from(affectedPaths).sort((a, b) => { @@ -509,6 +523,18 @@ export default class TreeRegistry { this.pendingRemovals.clear(); this.pendingTimestampUpdate = null; + if (perfEnabled) { + const now = performance.now(); + log.perf( + `TreeRegistry.flush completed: ` + + `phase1(removals)=${(phase2Start - phase1Start).toFixed(2)} ms, ` + + `phase2(upserts)=${(phase3Start - phase2Start).toFixed(2)} ms, ` + + `phase3(rehash)=${(now - phase3Start).toFixed(2)} ms | ` + + `matchMetadataStrictCalls=${matchMetadataStrictCalls}, ` + + `matchMetadataUnchanged=${matchMetadataUnchanged}, ` + + `modifiedNodesSkips=${modifiedNodesSkips}`); + } + return { added: addedResources, updated: updatedResources, diff --git a/packages/project/lib/build/cache/utils.js b/packages/project/lib/build/cache/utils.js index 03c86392815..c13835ea43e 100644 --- a/packages/project/lib/build/cache/utils.js +++ b/packages/project/lib/build/cache/utils.js @@ -6,6 +6,15 @@ * @property {number} size Size of the resource in bytes */ +const PERF_TRACKING = !!process.env.UI5_CACHE_PERF; +const perfCounters = { + calls: 0, + shortCircuitTrue: 0, + sizeMismatch: 0, + integrityFallback: 0, +}; +export {perfCounters as matchResourceMetadataStrictCounters}; + /** * Compares a resource instance with cached resource metadata * @@ -71,6 +80,7 @@ export async function matchResourceMetadataStrict(resource, cachedMetadata, inde if (!resource || !cachedMetadata) { throw new Error("Cannot compare undefined resources or metadata"); } + if (PERF_TRACKING) perfCounters.calls++; // Check 1: Inode mismatch would indicate file replacement (comparison only if inodes are provided) // const currentInode = resource.getInode(); @@ -84,6 +94,7 @@ export async function matchResourceMetadataStrict(resource, cachedMetadata, inde if (currentLastModified === cachedMetadata.lastModified) { if (indexTimestamp && currentLastModified !== indexTimestamp) { // File has not been modified since last indexing. No update needed + if (PERF_TRACKING) perfCounters.shortCircuitTrue++; return true; } // else: Edge case. File modified exactly at index time // Race condition possible - content may have changed during indexing @@ -93,11 +104,13 @@ export async function matchResourceMetadataStrict(resource, cachedMetadata, inde // Check 3: Size mismatch indicates definite content change const currentSize = await resource.getSize(); if (currentSize !== cachedMetadata.size) { + if (PERF_TRACKING) perfCounters.sizeMismatch++; return false; } // Check 4: Compare integrity (expensive) // lastModified has changed, but the content might be the same. E.g. in case of a metadata-only update + if (PERF_TRACKING) perfCounters.integrityFallback++; const currentIntegrity = await resource.getIntegrity(); return currentIntegrity === cachedMetadata.integrity; } From 2a8fa558c20fe9dd0ffd372c008319d7eaab5b61 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 13:47:28 +0200 Subject: [PATCH 201/223] perf(project): Add orchestration-level timing instrumentation Add perf-level timing to the build orchestration layer to identify the ~6.5s gap observed in warm-cache builds between source index initialization and per-project cache validation. Instruments: - getRequiredProjectContexts (total + per-project context creation) - prepareProjectBuildAndValidateCache (getDependenciesReader vs cache) - #flushPendingChanges (source index vs dependency index updates) - ProjectBuilder.#build per-project timing --- packages/project/lib/build/ProjectBuilder.js | 12 ++++++++++++ .../project/lib/build/cache/ProjectBuildCache.js | 15 +++++++++++++++ .../project/lib/build/helpers/BuildContext.js | 14 ++++++++++++++ .../lib/build/helpers/ProjectBuildContext.js | 11 +++++++++++ 4 files changed, 52 insertions(+) diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 45e418787e0..429e4f4f69f 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -274,7 +274,12 @@ class ProjectBuilder { } this.#buildIsRunning = true; this.#log.info(`Preparing build for projects: ${requestedProjects.join(", ")}`); + const reqStart = performance.now(); const projectBuildContexts = await this._buildContext.getRequiredProjectContexts(requestedProjects); + if (this.#log.isLevelEnabled("perf")) { + this.#log.perf( + `getRequiredProjectContexts completed in ${(performance.now() - reqStart).toFixed(2)} ms`); + } // Create build queue based on graph depth-first search to ensure correct build order const queue = []; @@ -318,7 +323,14 @@ class ProjectBuilder { if (alreadyBuilt.includes(projectName)) { this.#log.skipProjectBuild(projectName, projectType); } else { + const prepStart = performance.now(); const usesCache = await projectBuildContext.prepareProjectBuildAndValidateCache(); + if (this.#log.isLevelEnabled("perf")) { + this.#log.perf( + `prepareProjectBuildAndValidateCache for ${projectName} ` + + `completed in ${(performance.now() - prepStart).toFixed(2)} ms ` + + `(usesCache=${usesCache})`); + } if (usesCache) { this.#log.skipProjectBuild(projectName, projectType); alreadyBuilt.push(projectName); diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 821982710f7..8fa4e533a58 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -192,11 +192,19 @@ export default class ProjectBuildCache { let sourceIndexChanged = false; if (this.#changedProjectSourcePaths.length) { // Update source index so we can use the signature later as part of the result stage signature + const sourceStart = performance.now(); sourceIndexChanged = await this.#updateSourceIndex(this.#changedProjectSourcePaths); + if (log.isLevelEnabled("perf")) { + log.perf( + `#flushPendingChanges updateSourceIndex for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - sourceStart).toFixed(2)} ms ` + + `(${this.#changedProjectSourcePaths.length} changed paths, changed=${sourceIndexChanged})`); + } } let depIndicesChanged = false; if (this.#changedDependencyResourcePaths.length) { + const depStart = performance.now(); await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { const changed = await taskCache .updateDependencyIndices(this.#currentDependencyReader, this.#changedDependencyResourcePaths); @@ -204,6 +212,13 @@ export default class ProjectBuildCache { depIndicesChanged = true; } })); + if (log.isLevelEnabled("perf")) { + log.perf( + `#flushPendingChanges updateDependencyIndices for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - depStart).toFixed(2)} ms ` + + `(${this.#changedDependencyResourcePaths.length} changed paths, ` + + `${this.#taskCache.size} tasks, changed=${depIndicesChanged})`); + } } // Reset pending changes diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 791e0cbaf70..4dba498193e 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -2,6 +2,8 @@ import ProjectBuildContext from "./ProjectBuildContext.js"; import OutputStyleEnum from "./ProjectBuilderOutputStyle.js"; import CacheManager from "../cache/CacheManager.js"; import {getBaseSignature} from "./getBuildSignature.js"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:helpers:BuildContext"); /** * Context of a build process @@ -114,11 +116,18 @@ class BuildContext { } async getRequiredProjectContexts(requestedProjects) { + const totalStart = performance.now(); const projectBuildContexts = new Map(); const requiredProjects = new Set(requestedProjects); for (const projectName of requiredProjects) { + const contextStart = performance.now(); const projectBuildContext = await this.getProjectContext(projectName); + if (log.isLevelEnabled("perf")) { + log.perf( + `getProjectContext for ${projectName} completed in ` + + `${(performance.now() - contextStart).toFixed(2)} ms`); + } projectBuildContexts.set(projectName, projectBuildContext); @@ -130,6 +139,11 @@ class BuildContext { requiredProjects.add(depName); } } + if (log.isLevelEnabled("perf")) { + log.perf( + `getRequiredProjectContexts completed in ${(performance.now() - totalStart).toFixed(2)} ms ` + + `for ${projectBuildContexts.size} projects`); + } return projectBuildContexts; } diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 0df6b74f854..5ffe1b01db3 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -246,11 +246,22 @@ class ProjectBuildContext { * True if a valid cache was found and is being used. False otherwise (indicating a build is required). */ async prepareProjectBuildAndValidateCache() { + const readerStart = performance.now(); const depReader = await this.getTaskRunner().getDependenciesReader( await this.getTaskRunner().getRequiredDependencies(), true, // Force creation of new reader since project readers might have changed during their (re-)build ); + if (this._log.isLevelEnabled("perf")) { + this._log.perf( + `getDependenciesReader completed in ${(performance.now() - readerStart).toFixed(2)} ms`); + } + const cacheStart = performance.now(); const boolOrChangedPaths = await this.getBuildCache().prepareProjectBuildAndValidateCache(depReader); + if (this._log.isLevelEnabled("perf")) { + this._log.perf( + `ProjectBuildCache.prepareProjectBuildAndValidateCache completed in ` + + `${(performance.now() - cacheStart).toFixed(2)} ms`); + } if (Array.isArray(boolOrChangedPaths)) { // Cache can be used, but some resources have changed // Propagate changed paths to dependents From 1e3311d6ce14b85bdc4f017f184fdffa1585e57f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 14:09:33 +0200 Subject: [PATCH 202/223] perf(project): Parallelize source index initialization across projects Defer source index initialization from ProjectBuildCache.create() to a separate initSourceIndex() method, allowing BuildContext to initialize all project source indices concurrently via Promise.all instead of sequentially in the dependency discovery loop. --- .../lib/build/cache/ProjectBuildCache.js | 23 +++++++++++++--- .../project/lib/build/helpers/BuildContext.js | 22 +++++++++++----- .../lib/build/helpers/ProjectBuildContext.js | 12 +++++++++ .../test/lib/build/cache/ProjectBuildCache.js | 26 +++++++++++++++++++ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 8fa4e533a58..57ed4812814 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -95,15 +95,30 @@ export default class ProjectBuildCache { * @returns {Promise<@ui5/project/build/cache/ProjectBuildCache>} Initialized cache instance */ static async create(project, buildSignature, cacheManager) { - const cache = new ProjectBuildCache(project, buildSignature, cacheManager); + return new ProjectBuildCache(project, buildSignature, cacheManager); + } + + /** + * Initializes the source index for this project's build cache + * + * This must be called after create() and before any cache operations that depend on the source index. + * Separated from create() to allow parallel initialization of multiple project caches. + * + * @public + * @returns {Promise} + */ + async initSourceIndex() { + if (this.#combinedIndexState !== INDEX_STATES.RESTORING_PROJECT_INDICES) { + // Already initialized (e.g. reused across builds) + return; + } const initStart = performance.now(); - await cache.#initSourceIndex(); + await this.#initSourceIndex(); if (log.isLevelEnabled("perf")) { log.perf( - `Initialized source index for project ${project.getName()} ` + + `Initialized source index for project ${this.#project.getName()} ` + `in ${(performance.now() - initStart).toFixed(2)} ms`); } - return cache; } /** diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index 4dba498193e..ee868ab3b3b 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -120,14 +120,11 @@ class BuildContext { const projectBuildContexts = new Map(); const requiredProjects = new Set(requestedProjects); + // Phase 1: Discover all required projects (sequential — each project's + // dependencies may expand the set). This is fast because getProjectContext + // no longer triggers source index I/O. for (const projectName of requiredProjects) { - const contextStart = performance.now(); const projectBuildContext = await this.getProjectContext(projectName); - if (log.isLevelEnabled("perf")) { - log.perf( - `getProjectContext for ${projectName} completed in ` + - `${(performance.now() - contextStart).toFixed(2)} ms`); - } projectBuildContexts.set(projectName, projectBuildContext); @@ -139,6 +136,19 @@ class BuildContext { requiredProjects.add(depName); } } + + // Phase 2: Initialize all source indices in parallel + const initStart = performance.now(); + await Promise.all( + Array.from(projectBuildContexts.values()).map((ctx) => ctx.initSourceIndex()) + ); + if (log.isLevelEnabled("perf")) { + log.perf( + `Parallel source index initialization completed in ` + + `${(performance.now() - initStart).toFixed(2)} ms ` + + `for ${projectBuildContexts.size} projects`); + } + if (log.isLevelEnabled("perf")) { log.perf( `getRequiredProjectContexts completed in ${(performance.now() - totalStart).toFixed(2)} ms ` + diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 5ffe1b01db3..426fecb140a 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -66,6 +66,18 @@ class ProjectBuildContext { ); } + /** + * Initializes the source index for this project's build cache + * + * Must be called after create() and before any cache operations. + * Separated from create() to allow parallel initialization of multiple projects. + * + * @returns {Promise} + */ + async initSourceIndex() { + await this._buildCache.initSourceIndex(); + } + /** * Checks whether this context is for the root project * diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 8a210cfac40..8a9ec18e08a 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -96,6 +96,7 @@ test("Create ProjectBuildCache instance", async (t) => { const buildSignature = "test-signature"; const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + await cache.initSourceIndex(); t.truthy(cache, "ProjectBuildCache instance created"); t.true(cacheManager.readIndexCache.called, "Index cache was attempted to be loaded"); @@ -163,6 +164,7 @@ test("Create with existing index cache", async (t) => { cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + await cache.initSourceIndex(); t.truthy(cache, "Cache created with existing index"); const taskCache = cache.getTaskCache("task1"); @@ -175,6 +177,7 @@ test("Initialize without any cache", async (t) => { const buildSignature = "test-signature"; const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + await cache.initSourceIndex(); t.false(cache.isFresh(), "Cache is not fresh when empty"); }); @@ -183,6 +186,7 @@ test("isFresh returns false for empty cache", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); t.false(cache.isFresh(), "Empty cache is not fresh"); }); @@ -191,6 +195,7 @@ test("getTaskCache returns undefined for non-existent task", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); t.is(cache.getTaskCache("nonexistent"), undefined, "Returns undefined"); }); @@ -201,6 +206,7 @@ test("setTasks initializes project stages", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks(["task1", "task2", "task3"]); @@ -216,6 +222,7 @@ test("setTasks with empty task list", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks([]); @@ -226,6 +233,7 @@ test("allTasksCompleted switches to result stage", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); const changedPaths = await cache.allTasksCompleted(); @@ -269,6 +277,7 @@ test("allTasksCompleted returns changed resource paths", async (t) => { cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); // Simulate some changes - change tracking happens during prepareProjectBuildAndValidateCache cache.projectSourcesChanged(["/test.js"]); @@ -284,6 +293,7 @@ test("prepareTaskExecutionAndValidateCache: task needs execution when no cache e const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks(["myTask"]); const canUseCache = await cache.prepareTaskExecutionAndValidateCache("myTask"); @@ -296,6 +306,7 @@ test("prepareTaskExecutionAndValidateCache: switches project to correct stage", const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks(["task1", "task2"]); await cache.prepareTaskExecutionAndValidateCache("task2"); @@ -307,6 +318,7 @@ test("recordTaskResult: creates task cache", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks(["newTask"]); await cache.prepareTaskExecutionAndValidateCache("newTask"); @@ -324,6 +336,7 @@ test("recordTaskResult with empty requests", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks(["task1"]); await cache.prepareTaskExecutionAndValidateCache("task1"); @@ -374,6 +387,7 @@ test("projectSourcesChanged: marks cache as requiring validation", async (t) => cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); cache.projectSourcesChanged(["/test.js"]); @@ -415,6 +429,7 @@ test("dependencyResourcesChanged: marks cache as requiring validation", async (t cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); cache.dependencyResourcesChanged(["/dep.js"]); @@ -425,6 +440,7 @@ test("projectSourcesChanged: tracks multiple changes", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); cache.projectSourcesChanged(["/test1.js"]); cache.projectSourcesChanged(["/test2.js", "/test3.js"]); @@ -437,6 +453,7 @@ test("prepareProjectBuildAndValidateCache: returns false for empty cache", async const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); const mockDependencyReader = { byGlob: sinon.stub().resolves([]), @@ -510,6 +527,7 @@ test("_refreshDependencyIndices: updates dependency indices", async (t) => { cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); const mockDependencyReader = { byGlob: sinon.stub().resolves([]), @@ -527,6 +545,7 @@ test("writeCache: writes index and stage caches", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); project.getReader.returns({ byGlob: sinon.stub().resolves([]), @@ -573,6 +592,7 @@ test("writeCache: skips writing unchanged caches", async (t) => { cacheManager.readIndexCache.resolves(indexCache); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); project.getReader.returns({ byGlob: sinon.stub().resolves([]), @@ -596,6 +616,7 @@ test("Create cache with empty project name", async (t) => { const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); t.truthy(cache, "Cache created with empty project name"); }); @@ -604,6 +625,7 @@ test("Empty task list doesn't fail", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks([]); @@ -630,6 +652,7 @@ async function buildCacheWithTaskResult(resources, writtenPaths = []) { })); const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + await cache.initSourceIndex(); // Set up and execute a task await cache.setTasks(["myTask"]); @@ -757,6 +780,7 @@ test("freezeUntransformedSources: throws when source file not found", async (t) })); const cache = await ProjectBuildCache.create(project, "test-sig", cacheManager); + await cache.initSourceIndex(); await cache.setTasks(["myTask"]); await cache.prepareTaskExecutionAndValidateCache("myTask"); @@ -863,6 +887,7 @@ test("restoreFrozenSources: cache miss skips gracefully", async (t) => { }); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); const mockDepReader = { byGlob: sinon.stub().resolves([]), @@ -949,6 +974,7 @@ test("restoreFrozenSources: cache hit creates CAS reader", async (t) => { }); const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); const mockDepReader = { byGlob: sinon.stub().resolves([]), From 937b62e6bdcad1a4dff157d9fd232ec7015d409d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 14:30:43 +0200 Subject: [PATCH 203/223] perf(project): Defer cacache I/O in cached stage proxy readers During dependency index updates, the cache proxy reader eagerly called cacache.get.info() for every byPath() request, even though the callers (hash tree upserts) only need resource metadata. With 2550+ parallel calls, this created severe filesystem I/O contention (~2.2s). Defer the cache path resolution to first content access using a lazy singleton promise pattern. Also replace O(n) Array.includes() with O(1) object property lookup and fix the size/byteSize parameter mismatch. sap.ui.layout updateDependencyIndices: 2466ms -> 59ms Total warm-cache build: 2.85s -> 315ms --- .../lib/build/cache/ProjectBuildCache.js | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 57ed4812814..1d80de8a960 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -3,6 +3,7 @@ import {getLogger} from "@ui5/logger"; import fs from "graceful-fs"; import {promisify} from "node:util"; import crypto from "node:crypto"; +import {PassThrough} from "node:stream"; import {gunzip, createGunzip} from "node:zlib"; const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; @@ -1389,7 +1390,7 @@ export default class ProjectBuildCache { return allResourcePaths; }, getResource: async (virPath) => { - if (!allResourcePaths.includes(virPath)) { + if (!(virPath in resourceMetadata)) { return null; } const {lastModified, size, integrity, inode} = resourceMetadata[virPath]; @@ -1398,28 +1399,48 @@ export default class ProjectBuildCache { throw new Error(`Incomplete metadata for resource ${virPath} of task ${stageId} ` + `in project ${this.#project.getName()}`); } - // Get path to cached file contend stored in cacache via CacheManager - const cachePath = await this.#cacheManager.getResourcePathForStage( - this.#buildSignature, stageId, stageSignature, virPath, integrity); - if (!cachePath) { - throw new Error(`Unexpected cache miss for resource ${virPath} of task ${stageId} ` + - `in project ${this.#project.getName()}`); - } + + // Lazily resolve the cache path on first content access. + // This avoids cacache.get.info() I/O during index updates where + // only metadata (lastModified, size, integrity, inode) is needed. + let cachePathPromise; + const resolveCachePath = () => { + if (!cachePathPromise) { + cachePathPromise = this.#cacheManager.getResourcePathForStage( + this.#buildSignature, stageId, stageSignature, virPath, integrity + ).then((cachePath) => { + if (!cachePath) { + throw new Error( + `Unexpected cache miss for resource ${virPath} of task ${stageId} ` + + `in project ${this.#project.getName()}`); + } + return cachePath; + }); + } + return cachePathPromise; + }; + return createResource({ path: virPath, - sourceMetadata: { - fsPath: cachePath - }, createStream: () => { - // Decompress the gzip-compressed stream - return fs.createReadStream(cachePath).pipe(createGunzip()); + // createStream must return a stream synchronously. + // Use a PassThrough as a bridge to defer the async cache path resolution. + const passThrough = new PassThrough(); + resolveCachePath().then((cachePath) => { + const src = fs.createReadStream(cachePath).pipe(createGunzip()); + src.pipe(passThrough); + src.on("error", (err) => passThrough.destroy(err)); + }).catch((err) => { + passThrough.destroy(err); + }); + return passThrough; }, createBuffer: async () => { - // Decompress the gzip-compressed buffer + const cachePath = await resolveCachePath(); const compressedBuffer = await readFile(cachePath); return await promisify(gunzip)(compressedBuffer); }, - size, + byteSize: size, lastModified, integrity, inode, From 6ff5e004bed6c1550ead916b1b019fd17bac3e12 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 17 Apr 2026 14:46:51 +0200 Subject: [PATCH 204/223] perf(project): Skip redundant dependency index updates for tasks without requests --- packages/project/lib/build/cache/BuildTaskCache.js | 10 ++++++++++ packages/project/lib/build/cache/ProjectBuildCache.js | 10 +++++++--- .../project/lib/build/cache/ResourceRequestManager.js | 10 ++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/build/cache/BuildTaskCache.js b/packages/project/lib/build/cache/BuildTaskCache.js index b2aac3cfe90..d01410f9e50 100644 --- a/packages/project/lib/build/cache/BuildTaskCache.js +++ b/packages/project/lib/build/cache/BuildTaskCache.js @@ -151,6 +151,16 @@ export default class BuildTaskCache { return this.#dependencyRequestManager.updateIndices(dependencyReader, changedDepResourcePaths); } + /** + * Returns whether this task has any recorded dependency resource requests + * + * @public + * @returns {boolean} + */ + hasDependencyRequests() { + return this.#dependencyRequestManager.hasRequests(); + } + /** * Performs a full refresh of the dependency resource index * diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 1d80de8a960..17479008c2c 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -221,7 +221,9 @@ export default class ProjectBuildCache { let depIndicesChanged = false; if (this.#changedDependencyResourcePaths.length) { const depStart = performance.now(); - await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { + const tasksWithDepRequests = Array.from(this.#taskCache.values()) + .filter((taskCache) => taskCache.hasDependencyRequests()); + await Promise.all(tasksWithDepRequests.map(async (taskCache) => { const changed = await taskCache .updateDependencyIndices(this.#currentDependencyReader, this.#changedDependencyResourcePaths); if (changed) { @@ -233,7 +235,7 @@ export default class ProjectBuildCache { `#flushPendingChanges updateDependencyIndices for project ${this.#project.getName()} ` + `completed in ${(performance.now() - depStart).toFixed(2)} ms ` + `(${this.#changedDependencyResourcePaths.length} changed paths, ` + - `${this.#taskCache.size} tasks, changed=${depIndicesChanged})`); + `${tasksWithDepRequests.length}/${this.#taskCache.size} tasks, changed=${depIndicesChanged})`); } } @@ -258,7 +260,9 @@ export default class ProjectBuildCache { * @returns {Promise} */ async _refreshDependencyIndices(dependencyReader) { - await Promise.all(Array.from(this.#taskCache.values()).map(async (taskCache) => { + const tasksWithDepRequests = Array.from(this.#taskCache.values()) + .filter((taskCache) => taskCache.hasDependencyRequests()); + await Promise.all(tasksWithDepRequests.map(async (taskCache) => { await taskCache.refreshDependencyIndices(dependencyReader); })); // Reset pending dependency changes since indices are fresh now anyways diff --git a/packages/project/lib/build/cache/ResourceRequestManager.js b/packages/project/lib/build/cache/ResourceRequestManager.js index 64d1761ddb3..f7a211f1e4b 100644 --- a/packages/project/lib/build/cache/ResourceRequestManager.js +++ b/packages/project/lib/build/cache/ResourceRequestManager.js @@ -297,6 +297,16 @@ class ResourceRequestManager { return hasChanges; } + /** + * Returns whether any resource requests have been recorded + * + * @public + * @returns {boolean} + */ + hasRequests() { + return this.#requestGraph.getSize() > 0; + } + /** * Matches changed resources against a set of requests * From ffbbd3be3eaf7521a17f27f0016bd86d854d0a06 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 16 Apr 2026 18:25:40 +0200 Subject: [PATCH 205/223] docs: Add skills --- .claude/skills/incremental-build/SKILL.md | 38 ++ .../skills/incremental-build/architecture.md | 384 ++++++++++++++++++ .../performance-investigation.md | 377 +++++++++++++++++ AGENTS.md | 94 +++++ CLAUDE.md | 1 + 5 files changed, 894 insertions(+) create mode 100644 .claude/skills/incremental-build/SKILL.md create mode 100644 .claude/skills/incremental-build/architecture.md create mode 100644 .claude/skills/incremental-build/performance-investigation.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/incremental-build/SKILL.md b/.claude/skills/incremental-build/SKILL.md new file mode 100644 index 00000000000..739e21563ae --- /dev/null +++ b/.claude/skills/incremental-build/SKILL.md @@ -0,0 +1,38 @@ +--- +name: incremental-build +description: > + Work on the incremental (delta) build system in @ui5/project: build caching, + resource indexing, hash trees, stage caching, build server, file watching, + task execution caching, and delta builds. +when_to_use: > + TRIGGER when: the user asks about or wants to modify code related to incremental builds, + build caching, resource indexing, hash trees, stage caching, build server, file watching, + task execution caching, delta builds, resource tags in build context, or any component + listed in the Component Map in architecture.md. + DO NOT TRIGGER when: the user is working on unrelated CLI commands, general tooling, + or non-build features. +user-invocable: true +--- + +# Incremental Build Skill + +You are working on the incremental (delta) build system in `@ui5/project`. Read `architecture.md` in this skill directory for the full architecture reference, including the component map, key flows, caching architecture, and data structures. Read `performance-investigation.md` for guidance on profiling builds, reading perf logs, and known performance peculiarities. + +## Guidelines for Working on This Code + +1. **Always read the source file before modifying it.** The Component Map in `architecture.md` tells you where each piece lives. +2. **Understand the cache flow direction.** Changes propagate: source change -> index update -> signature change -> cache miss -> task re-execution. +3. **Be careful with tag side effects.** `getTags()` is not a pure read -- it triggers lazy tag application. Avoid calling it in unexpected contexts. +4. **Respect abort signals.** Any long-running operation should check `signal?.throwIfAborted()` periodically. +5. **Test with incremental rebuilds.** A single build passing is not enough; the interesting bugs appear on the second and third builds after file changes. +6. **Watch for stale stage readers after abort.** If a build is aborted, stage writers may contain partial results that shadow source files. +7. **Signature stability matters.** Any change to how hashes are computed (e.g., adding new fields to hash input) invalidates all existing caches. +8. **SharedHashTree operations must go through TreeRegistry.** Never mutate a SharedHashTree directly; always schedule via the registry and flush. + +## Known Constraints + +- `StageCache` (in-memory) has no `clear()` method -- stage entries persist for the lifetime of the `ProjectBuildCache` instance +- `ProjectBuildContext` instances (including their caches) are reused across sequential builds of the same project within a session +- `resource.getTags()` has side effects: it triggers `#applyCachedResourceTags()` and creates `MonitoredResourceTagCollection` instances, which modify tag collection state. This means calling `getTags()` during hash tree operations (e.g., in `TreeRegistry.flush()`) can affect the stage pipeline state. +- `updateProjectIndices` uses `project.getReader()` (stage pipeline reader) to read resources. After an aborted build, stage writers may contain in-memory resources that shadow updated source content, causing stale reads. +- `getSourcePaths()` and `getVirtualPath()` are NOT consistent with `getSourceReader()` for all project types. Module has no `getVirtualPath()` (base class throws). ThemeLibrary's `getSourcePaths()` returns only `[src/]` and `getVirtualPath()` only maps `src/`, but `_getReader()` also includes `test/` when `_testPathExists`. Any optimization that replaces `sourceReader.byGlob()` with direct filesystem enumeration via `getSourcePaths()` + `getVirtualPath()` must handle these mismatches. diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md new file mode 100644 index 00000000000..a3143161e61 --- /dev/null +++ b/.claude/skills/incremental-build/architecture.md @@ -0,0 +1,384 @@ +# Incremental Build Architecture + +## High-Level Overview + +The incremental build system enables fast development feedback loops by: +1. Building projects **lazily** -- only when their output is requested +2. **Caching** task results keyed by content hashes of input resources +3. **Skipping** tasks whose inputs haven't changed since the last build +4. Running **differential** (delta) builds that only process changed resources +5. **Watching** source files and automatically rebuilding on changes + +``` + +----------------+ + Resource Request > | BuildServer | --- file watcher ---> invalidate + abort + +-------+--------+ + | enqueue project + v + +----------------+ + | ProjectBuilder | --- for each project in dependency order + +-------+--------+ + | + +------------+------------+ + v v v + +-----------+ +----------+ +-------------------+ + |BuildContext| |TaskRunner| |ProjectBuildCache | + +-----------+ +----------+ +-------------------+ +``` + +## Component Map + +Use this table to locate source files. ALWAYS read the relevant source file before making changes. + +| Component | Location | Role | +|-----------|----------|------| +| `BuildServer` | `lib/build/BuildServer.js` | Development server, file watching, build orchestration | +| `ProjectBuilder` | `lib/build/ProjectBuilder.js` | Builds projects in dependency order | +| `BuildContext` | `lib/build/helpers/BuildContext.js` | Global build config, project context cache | +| `getBuildSignature` | `lib/build/helpers/getBuildSignature.js` | Build signature computation: `BUILD_SIG_VERSION` + build config + project config | +| `ProjectBuildContext` | `lib/build/helpers/ProjectBuildContext.js` | Per-project bridge between builder, tasks, and cache | +| `TaskRunner` | `lib/build/TaskRunner.js` | Task composition, execution loop, abort handling | +| `ProjectBuildCache` | `lib/build/cache/ProjectBuildCache.js` | Cache orchestration per project: index management, stage lookup, result recording | +| `BuildTaskCache` | `lib/build/cache/BuildTaskCache.js` | Per-task resource request tracking and index management | +| `StageCache` | `lib/build/cache/StageCache.js` | In-memory cache of stage results keyed by signature | +| `CacheManager` | `lib/build/cache/CacheManager.js` | Persistent cache I/O (filesystem) | +| `ResourceRequestManager` | `lib/build/cache/ResourceRequestManager.js` | Request graph, resource index updates, signature computation | +| `ResourceRequestGraph` | `lib/build/cache/ResourceRequestGraph.js` | DAG of request sets with delta encoding and best-parent optimization | +| `ResourceIndex` | `lib/build/cache/index/ResourceIndex.js` | Wrapper around hash trees with delta detection | +| `HashTree` | `lib/build/cache/index/HashTree.js` | Directory-based Merkle tree for resource hashing | +| `SharedHashTree` | `lib/build/cache/index/SharedHashTree.js` | HashTree with structural sharing via TreeRegistry | +| `TreeRegistry` | `lib/build/cache/index/TreeRegistry.js` | Batch update coordinator for shared trees | +| `TreeNode` | `lib/build/cache/index/TreeNode.js` | Merkle tree node (resource or directory) | +| `ProjectResources` | `lib/resources/ProjectResources.js` | Stage management, readers/writers, tag collections | +| `Stage` | `lib/resources/Stage.js` | Writer + cached tag operations per build stage | +| `MonitoredResourceTagCollection` | In `@ui5/fs` (separate repo) | Proxy tracking tag operations during task execution | +| `ResourceTagCollection` | In `@ui5/fs` (separate repo) | Base storage for resource tags | +| `Resource` | In `@ui5/fs` (separate repo) | `getTags()` delegates to project's tag collection | + +## Key Flows + +### Build Request Flow + +``` +reader.byPath("/test.js") + -> BuildServer checks ProjectBuildStatus + -> If not fresh: #enqueueBuild(projectName) + -> Debounced (10ms): #processBuildRequests() + -> Batch all pending projects + -> projectBuilder.build({projects, signal}) + -> On success: setReader(project.getReader({style: "runtime"})) + -> Resolve queued reader promises +``` + +### File Watch and Abort + +When a source file changes: +1. `WatchHandler` emits change event with project name and resource path +2. `_projectResourceChanged()` queues the change and calls `ProjectBuildStatus.invalidate()` on the affected project and all its dependents +3. `invalidate()` triggers the project's `AbortController`, which aborts the running build via `AbortSignal` +4. The build loop catches `AbortBuildError` and re-enqueues projects that aren't fresh +5. Queued resource changes are flushed via `#flushResourceChanges()` before the next build starts + +### State Machine (per project) + +``` +INITIAL -> (first build requested, invalidate()) -> INVALIDATED -> (build completes, setReader()) -> FRESH + | + (file change detected) + v + INVALIDATED + (abort + re-enqueue) +``` + +Note: There is no separate `BUILDING` state; `INVALIDATED` covers both "needs build" and "building in progress". The abort controller on `ProjectBuildStatus` cancels the running build on re-invalidation. + +## Caching Architecture + +### Cache Layers + +``` ++---------------------------------------------+ +| In-Memory (StageCache) | <- Fast, per-session +| signature -> {stage, writtenPaths, tagOps} | ++---------------------------------------------+ +| Persistent (CacheManager) | <- Across sessions +| ~/.ui5/buildCache/v0_2/ | +| cas/ (content-addressable, gz) | +| stageMetadata/ (stage results by sig) | +| taskMetadata/ (resource requests) | +| resultMetadata/(build result metadata) | +| index/ (resource index trees) | +| buildManifests/(project build metadata) | ++---------------------------------------------+ +``` + +### Signatures + +The cache uses content-based signatures at multiple levels: + +| Level | What it captures | Where computed | +|-------|-----------------|----------------| +| **Build signature** | `BUILD_SIG_VERSION` + build config via `getBaseSignature()`, per-project via `getProjectSignature()` (base + projectId + project config). Task-provided signatures planned but not yet integrated. | `getBuildSignature.js`, `ProjectBuildContext.create()` | +| **Source signature** | Merkle root of all source resources | `ResourceIndex` (source index) | +| **Task stage signature** | `projectIndexSignature-dependencyIndexSignature` | `ProjectBuildCache.prepareTaskExecutionAndValidateCache()` | +| **Result signature** | Combined source signature + last task stage signature | `ProjectBuildCache.#getResultStageSignature()` | + +### Source File CAS Storage (Frozen Sources) + +To prevent race conditions where a dependency's source files change between project builds in a multi-project build, untransformed source files are stored in CAS after each build completes: + +1. `#freezeUntransformedSources()` identifies source files not overlaid by any build task +2. Stores them in CAS and persists metadata as a stage cache entry keyed by the source index signature +3. Creates a CAS-backed reader and sets it via `ProjectResources.setFrozenSourceReader()` +4. On subsequent builds where the result cache is valid, `#restoreFrozenSources()` loads metadata from cache and recreates the CAS-backed reader without rebuilding + +At build completion, `#revalidateSourceIndex()` re-reads all source files and compares them against the source index. If any file was modified during the build, an error is thrown and the cache is not stored, preventing inconsistent results. In watch mode this triggers a rebuild. + +### First Build (no cache) + +``` +#initSourceIndex() + -> No index cache on disk + -> Create fresh ResourceIndex from all source resources + -> #combinedIndexState = INITIAL + +prepareProjectBuildAndValidateCache() + -> State is INITIAL -> return false (no cache to validate) + +For each task: + prepareTaskExecutionAndValidateCache(taskName) + -> No task cache exists (#taskCache empty) + -> return false (task must execute) + + [task executes] + + recordTaskResult(taskName, workspace, dependencies, cacheInfo) + -> Records resource requests -> creates hash trees (ResourceRequestManager.addRequests) + -> Creates BuildTaskCache with request patterns and indices + -> Reads stage writer for produced resources + -> Gets tag operations from MonitoredResourceTagCollection + -> Computes stage signature + -> Stores in StageCache (in-memory) via stageCache.addSignature() + +allTasksCompleted() + -> Sets #combinedIndexState = FRESH + -> Computes result signature + -> Resets #writtenResultResourcePaths +``` + +### Subsequent Build (with cache) + +``` +projectSourcesChanged(changedPaths) / dependencyResourcesChanged(changedPaths) + -> Records changed paths + -> Sets #combinedIndexState = REQUIRES_UPDATE + +prepareProjectBuildAndValidateCache() + -> State is REQUIRES_UPDATE + -> #flushPendingChanges(): + #updateSourceIndex(changedPaths) -> reads resources from source reader + -> upserts into source ResourceIndex + -> Adds changed paths to #writtenResultResourcePaths + Updates dependency indices for all task caches + -> #findResultCache() -> checks if overall result is still valid + -> If result cache valid: return true (skip entire project build) + +For each task: + prepareTaskExecutionAndValidateCache(taskName) + -> Task cache EXISTS (from previous build's recordTaskResult) + -> updateProjectIndices(reader, writtenResultResourcePaths) + -> ResourceRequestManager.updateIndices(): + Match changed paths against request graph + Read resources from reader + Upsert into task hash trees (via TreeRegistry.flush for shared trees) + -> Compute stage signatures from updated hash trees + -> #findStageCache(stageName, stageSignatures) + 1. Check in-memory StageCache first + 2. Fall back to persistent cache (CacheManager) + -> If found: apply cached stage, return true (skip task) + -> If not found: try delta signatures (previous signature -> new signature) + -> If delta found: return {cacheInfo} for differential execution + -> If nothing found: return false (full execution) +``` + +## Resource Indexing (Merkle Trees) + +### HashTree + +A directory-based Merkle tree where: +- **Leaf nodes** (resources): hash = `SHA-256(resource:{name}:{integrity}[:tags(...)])` +- **Directory nodes**: hash = `SHA-256(sorted child hashes concatenated)` +- **Root hash** = tree signature (used as cache key) + +Each resource node stores: `name`, `integrity`, `lastModified`, `size`, `inode`, `tags` + +Key operations: +- `upsertResources(resources, timestamp)`: Insert or update resources, recompute affected hashes +- `removeResources(paths)`: Remove resources, recompute affected hashes +- `_computeHash(node)`: Recursive hash computation + +The `matchResourceMetadataStrict` utility (`utils.js`) determines if a resource is "unchanged" using a tiered comparison (cheapest first): + +1. If `lastModified` matches cached value AND differs from `indexTimestamp`: unchanged (fast path) +2. If `lastModified` equals `indexTimestamp`: racy-git edge case -- file may have changed during indexing, fall through to integrity check +3. Compare `size` -- if different, changed +4. Compare `integrity` hash -- expensive, last resort + +Note: inode comparison is defined but currently commented out. `inode` is still stored in TreeNode for future use. + +### SharedHashTree and TreeRegistry + +Tasks make multiple resource requests (e.g., `byGlob("/**/*.js")`, `byPath("/manifest.json")`). Each request set gets its own hash tree, but they share common subtrees via `SharedHashTree`. + +``` +Task reads: + byGlob("/**/*.js") -> RequestSet A -> SharedHashTree A (all JS files) + byPath("/test.js") -> RequestSet B -> SharedHashTree B (derived from A, adds test.js) +``` + +**TreeRegistry** coordinates batch updates across all shared trees: +1. Changes scheduled via `scheduleUpsert()` / `scheduleRemoval()` +2. `flush()` applies all pending operations atomically +3. Shared nodes modified once, changes propagate to all trees referencing them + +### ResourceRequestManager + +Manages the request graph for a task -- delegates to `ResourceRequestGraph` for DAG storage of request sets with delta encoding. Each graph node stores only the requests added relative to its parent, and `addRequestSet()` automatically finds the best parent (largest subset) to minimize delta size. + +At runtime, each materialized request set references a `SharedHashTree` representing the resources currently matching that set. + +- `addRequests(recording, reader)`: Records path/glob requests, creates or reuses a request set in the graph, builds a resource index (SharedHashTree), returns signature +- `updateIndices(reader, changedPaths)`: Traverses graph breadth-first, matches changed paths against request patterns per node, batch-fetches resources, upserts into affected resource indices via TreeRegistry +- `getIndexSignatures()`: Returns current signatures for all request sets +- `getDeltas()`: Returns map of original -> new signature for changed request sets + +## Resource Tags + +### Tag Types + +| Tag | Scope | Persists across builds | Example | +|-----|-------|----------------------|---------| +| `ui5:IsDebugVariant` | Project | Yes | Set by minify task on `-dbg.js` files | +| `ui5:HasDebugVariant` | Project | Yes | Set by minify task on original `.js` files | +| `ui5:OmitFromBuildResult` | Build | No (cleared after each build) | Exclude resources from output | +| `ui5:IsBundle` | Build | No | Mark bundled resources | + +### Tag Flow Through Stages + +``` +initStages([stage1, stage2, ...]) + -> Creates Stage objects with empty writers and no cached tag ops + +useStage(stageId) + -> Sets #currentStageReadIndex = stageIdx - 1 + -> Resets monitored tag collections + +#applyCachedResourceTags() [called lazily from getResourceTagCollection()] + -> Imports cached tag operations from stages[#lastTagCacheImportIndex+1 .. #currentStageReadIndex] + -> Advances #lastTagCacheImportIndex + +getResourceTagCollection(resource, tag) + -> Calls #applyCachedResourceTags() + -> Creates MonitoredResourceTagCollection wrapping the live collection + -> MonitoredResourceTagCollection clones the collection at creation time + (so getAllTagsForResource returns INPUT state, before task modifies) + +resource.getTags() + -> Calls project.getResourceTagCollection(this).getAllTagsForResource(this) + -> Returns tags as {key: value} object or null +``` + +### Tags in Hash Trees + +Resource hashes incorporate tags when present: +``` +hashInput = `resource:${name}:${integrity}` +if (tags && Object.keys(tags).length > 0) { + hashInput += `:tags(${sortedTagString})` +} +``` + +This ensures that tag-only changes (e.g., a resource gaining `IsDebugVariant` after the minify task runs) invalidate the cache signature for downstream tasks. + +## Stage Pipeline + +Each task has its own **stage** with a writer. Resources written by a task go into that stage's writer. + +### Reader Construction + +`ProjectResources.getReader()` creates a prioritized reader stack: +1. Current stage writer (highest priority) +2. Previous stage writers (in reverse order) +3. Frozen source reader (CAS-backed, if set) +4. Source reader (lowest priority, reads from filesystem) + +This means a task sees the cumulative output of all previous tasks, with its own writes taking highest priority. The frozen source reader ensures downstream consumers read an immutable CAS snapshot rather than the live filesystem. + +### Stage Cache + +When a task is skipped (cache hit), its cached stage is restored: +```javascript +project.getProjectResources().setStage(stageName, stageCache.stage, + stageCache.projectTagOperations, stageCache.buildTagOperations); +``` + +## Persistent Cache Format + +### On Disk (CacheManager) + +``` +~/.ui5/buildCache/v0_2/ ++-- cas/ # Content-addressable storage (cacache, gzip) +| +-- content-v2/ +| +-- sha256/... # Resources stored by integrity hash ++-- buildManifests/ # Build metadata per project (plain JSON) +| +-- {projectId}/ +| +-- {buildSignature}.json ++-- stageMetadata/ +| +-- {projectId}/ +| +-- {buildSignature}/ +| +-- {stageId}/ +| +-- {stageSignature}.json ++-- taskMetadata/ +| +-- {projectId}/ +| +-- {buildSignature}/ +| +-- {taskName}/ +| +-- {type}.json # type = "project" | "dependency" ++-- resultMetadata/ +| +-- {projectId}/ +| +-- {buildSignature}/ +| +-- {resultSignature}.json ++-- index/ + +-- {projectId}/ + +-- {kind}-{buildSignature}.json # kind = "source" | "result" +``` + +Note: Only CAS content (resource bodies) is gzip-compressed. All metadata files are plain JSON. + +#### Index Cache Contents + +The index cache (`{kind}-{buildSignature}.json`) contains: +- `indexTimestamp`: creation timestamp (used for racy-git detection) +- `root`: serialized Merkle tree (TreeNode hierarchy) +- `tasks`: array of `[taskName, supportsDifferentialBuilds ? 1 : 0]` recording the task execution order and differential build capability + +#### Stage Metadata Format + +Stage metadata stored on disk includes: +- `resourceMetadata`: resource paths mapped to `{integrity, lastModified, size, inode}` +- `resourceMapping` (optional, for WriterCollection stages): virtual path prefixes mapped to indices in the `resourceMetadata` array, supporting project types where multiple virtual paths map to the same physical path +- `projectTagOperations` / `buildTagOperations`: tag operations to apply when restoring the cached stage + +## Key Architectural Patterns + +1. **Lazy building**: Projects built on-demand when readers are requested +2. **Request batching**: Multiple pending build requests processed in single batch (10ms debounce) +3. **Abort/retry**: File changes abort running builds; projects re-queued automatically +4. **Structural sharing**: Derived hash trees share unchanged subtrees, reducing memory +5. **Content-addressed storage**: Resources deduplicated via integrity hashes in cacache +6. **Differential caching**: Tasks track resource requests; delta builds only re-process changed resources +7. **Tag propagation**: Resource tags flow through stages via cached tag operations, included in hash signatures +8. **Two-tier cache**: Fast in-memory StageCache + persistent filesystem cache via CacheManager +9. **Two-phase invalidation**: Changes queued via `projectSourcesChanged()` / `dependencyResourcesChanged()` (state -> `REQUIRES_UPDATE`), applied only during `#flushPendingChanges()` at next build start. "Definitely invalidated" only after content comparison confirms actual differences. +10. **Source index revalidation**: At build completion, `#revalidateSourceIndex()` re-reads source files and compares against the source index. If any file changed during the build, an error is thrown and the cache is not stored. +11. **Frozen sources**: Untransformed source files stored in CAS after build, providing immutable snapshots for downstream dependency consumers (prevents filesystem race conditions) diff --git a/.claude/skills/incremental-build/performance-investigation.md b/.claude/skills/incremental-build/performance-investigation.md new file mode 100644 index 00000000000..5742ef644a8 --- /dev/null +++ b/.claude/skills/incremental-build/performance-investigation.md @@ -0,0 +1,377 @@ +# Performance Investigation Guide + +Reference for investigating and analyzing performance of the incremental build system. + +## Test Setup + +### Test library + +Use sap.m from the OpenUI5 repository. It's a large library (~12,700 source resources) with 3 dependency projects (sap.ui.core, sap.ui.layout, sap.ui.unified) and 9 build tasks, making it a good stress test. + +### Build command + +```bash +# Within openui5/src/sap.m: +UI5_CLI_NO_LOCAL=X UI5_BUILD_NO_WRITE_DEST=X UI5_CACHE_PERF=1 ui5 build --log-level perf 2>&1 +``` + +Flags: +- `UI5_CLI_NO_LOCAL=X` — Use the globally linked CLI (i.e., the development version) +- `UI5_BUILD_NO_WRITE_DEST=X` — Skip writing output to `./dist` (isolates cache/build overhead from disk I/O) +- `UI5_CACHE_PERF=1` — Enable low-level `matchResourceMetadataStrict` counters (see below) +- `--log-level perf` — Show all perf-level log statements + +### Three build scenarios + +| Scenario | How to trigger | What it tests | +|----------|---------------|---------------| +| **Cold cache** | Delete `~/.ui5/buildCache/` and run | Full build + cache creation from scratch | +| **Warm cache** | Run twice with no file changes | Cache validation + result restoration | +| **Stale cache** | Edit a file, then run | Cache invalidation + partial rebuild | + +**Creating a stale cache scenario:** +Change a variable value inside a JS file (do NOT replace the first line — if it contains a license comment or `/*!`, replacing it can break minification): +```bash +# Within openui5/src/sap.m: +sed -i '' 's/BADGE_MAX_VALUE = 9999;/BADGE_MAX_VALUE = 9998;/' src/sap/m/Button.js +``` +Then run the build command. Restore with `git checkout -- src/sap/m/Button.js` when done. + +## Performance Logging API + +The `@ui5/logger` package provides a `perf` log level for performance instrumentation. This is the primary mechanism for adding timing measurements to build code. + +### Log levels (least to most restrictive) + +`silly` → `verbose` → `perf` → `info` (default) → `warn` → `error` → `silent` + +Setting `--log-level perf` shows `perf`, `info`, `warn`, and `error` messages. At the default `info` level, `perf` messages are suppressed. + +### Usage pattern + +```javascript +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:cache:MyModule"); + +// Guard expensive timing computation behind isLevelEnabled +if (log.isLevelEnabled("perf")) { + log.perf(`Operation completed in ${(performance.now() - start).toFixed(2)} ms`); +} +``` + +Key points: +- **Always guard with `log.isLevelEnabled("perf")`** when the log message involves computation (e.g., `performance.now()` calls, string interpolation with counts). Without the guard, the timing overhead runs even at default log level. +- `log.perf(msg)` works like `log.info(msg)` — accepts any number of arguments, joins them with spaces. +- `log.isLevelEnabled(level)` is a static check comparing the requested level's index against the current global level. +- Logger names use colon-separated namespaces (e.g., `"build:cache:ProjectBuildCache"`). + +### Enabling perf logging + +Two CLI options (defined in `packages/cli/lib/cli/base.js`): +- `--log-level perf` — Set log level directly +- `--perf` — Shorthand boolean flag, equivalent to `--log-level perf` + +### Adding new perf instrumentation + +Standard pattern for timing a code section: + +```javascript +const start = log.isLevelEnabled("perf") ? performance.now() : 0; +// ... operation to measure ... +if (log.isLevelEnabled("perf")) { + log.perf(`Operation X for project ${name} completed in ${(performance.now() - start).toFixed(2)} ms`); +} +``` + +When the guarded block is large or complex, prefer the two-check pattern above. For simple one-liners where the start timestamp is cheap, a single check around the log call suffices. + +## Reading the Perf Log + +The log follows the build lifecycle. Here is the anatomy of a stale-cache build: + +### Phase 1: Source Index Initialization (parallel across all projects) + +``` +perf #initSourceIndex fromCacheWithDelta for project sap.m completed in 68 ms: 12677 resources, 1 changed +perf Initialized source index for project sap.m in 691 ms +``` + +- The first line shows the delta detection: how many resources exist and how many changed. + A high `changed` count with few actual edits suggests a timestamp-related false positive. +- The total init time includes `byGlob("/**/*")` (stat all files), reading the cached index from disk, and delta detection. The `fromCacheWithDelta` time is a subset of the total. +- If `fromCacheWithDelta` is small relative to total, the bottleneck is in the glob (reading file metadata from disk). If `fromCacheWithDelta` is large, the bottleneck is in hashing. + +### Phase 2: Per-project cache validation (sequential) + +Each project goes through this sequence: + +``` +perf getDependenciesReader completed in 0.12 ms +perf Initialized dependency indices for project sap.ui.core in 0.63 ms + OR +perf Skipping dependency index refresh for project sap.ui.core (no dependency changes propagated) + OR +perf #flushPendingChanges updateDependencyIndices for project sap.ui.layout completed in 61 ms (3064 changed paths, 2/10 tasks, changed=true) +perf Flushed pending changes for project sap.ui.layout in 61 ms +``` + +- **"Initialized dependency indices"** → first-time dependency index refresh (`_refreshDependencyIndices`). Runs when dependency changes were propagated from upstream projects. +- **"Skipping dependency index refresh"** → no dependency changes were propagated; cached indices are already correct. +- **"#flushPendingChanges"** → updating existing indices with changed paths (BuildServer subsequent builds). The `N/M tasks` shows how many tasks had dependency requests vs. total. The `changed paths` count is the number of dependency resource paths propagated as potentially changed. + +Then result cache validation: + +``` +perf #findResultCache importStages for project sap.ui.core completed in 7 ms with 10 stages +perf #findResultCache restoreFrozenSources for project sap.ui.core completed in 5 ms +perf Validated result cache for project sap.ui.core in 13 ms +``` + +- `importStages` — Loads cached stage readers from CAS for each task. +- `restoreFrozenSources` — Creates a CAS-backed reader for source files. +- The overall line tells you if the result cache was valid. + +After validation: + +``` +perf prepareProjectBuildAndValidateCache for sap.ui.core completed in 34 ms (usesCache=true) +info ✔ Skipping build of library project sap.ui.core + OR +perf prepareProjectBuildAndValidateCache for sap.m completed in 0.48 ms (usesCache=false) +info ❯ Building library project sap.m... +``` + +- `usesCache=true` → Project is fully served from cache, skip all tasks. +- `usesCache=false` → Project needs (re-)building, tasks will be evaluated individually. + +### Phase 3: Task execution (for projects that need building) + +Per-task index update: + +``` +perf updateIndices for task 'replaceVersion' of project 'sap.m' resource fetch completed in 3.89 ms: 0 cache hits, 783 cache misses +perf TreeRegistry.flush completed: phase1(removals)=0.00 ms, phase2(upserts)=7.13 ms, phase3(rehash)=9.56 ms | matchMetadataStrictCalls=783, matchMetadataUnchanged=782, modifiedNodesSkips=0 +perf #flushTreeChanges for task 'replaceVersion' completed in 16.79 ms across 1 registries +perf Updated project indices for task replaceVersion in project sap.m in 21.71 ms +``` + +Key metrics: +- **cache hits / cache misses**: In the resource fetch, "cache hits" means the resource was already in the task's request index (from a previous task sharing the same tree). "Cache misses" means a new fetch was needed. +- **phase1(removals)**: Time removing deleted resources from the Merkle tree. +- **phase2(upserts)**: Time inserting/updating resources in the tree. +- **phase3(rehash)**: Time propagating hash changes up from modified leaves to root. High rehash with few changed resources indicates deep tree structure. +- **matchMetadataStrictCalls / matchMetadataUnchanged**: How many resources were compared, and how many were unchanged (short-circuited by lastModified check). If `calls >> unchanged`, many resources needed content hashing. + +Then task execution or skip: + +``` +info ✔ Skipping task escapeNonAsciiCharacters ← Cache hit +info ◇ Running task replaceCopyright... ← Delta/differential run +info › Running task generateLibraryPreload... ← Full re-execution +``` + +- `✔ Skipping` — exact cache match for this task's signature. +- `◇ Running` — differential execution (using `changedProjectResourcePaths`). +- `› Running` — full execution (no cache match, no delta available). + +After task execution, `recordTaskResult` runs (logged per task): + +``` +perf recordTaskResult delta merge for task replaceCopyright in project sap.m completed in 6.67 ms (783 previous, 782 merged) +perf recordTaskResult for task replaceCopyright in project sap.m completed in 7.22 ms (1 written resources, delta=true) +``` + +### Phase 3b: Build finalization (`allTasksCompleted`) + +After all tasks complete, `allTasksCompleted()` runs two operations: + +``` +perf #revalidateSourceIndex byGlob for project sap.m completed in 284.91 ms (12677 resources) +perf allTasksCompleted #revalidateSourceIndex for project sap.m completed in 326.33 ms (changed=false) +perf #freezeUntransformedSources for project sap.m: reused all 11821 entries from previous metadata +perf allTasksCompleted #freezeUntransformedSources for project sap.m completed in 30.95 ms +perf allTasksCompleted for project sap.m completed in 357.45 ms (2235 changed paths) +``` + +Key metrics: +- **`#revalidateSourceIndex` byGlob**: Time to re-read all source file metadata from disk. I/O-bound. +- **`#revalidateSourceIndex` total**: Includes metadata comparison loop. If much larger than byGlob, the comparison is expensive (racy-git fallback to integrity check). +- **`#freezeUntransformedSources` metadata reuse**: When `#cachedFrozenSourceMetadata` is populated (stale cache), untransformed paths found in the previous metadata are reused directly with no I/O. The log shows "reused all N entries" for the fast path, or "M of N resources" for the delta path (where M paths needed re-reading). +- **`#freezeUntransformedSources` byPath reads**: Only shown when some paths need reading (delta or cold-cache path). Format: `(M of N resources)` for delta, `(N resources)` for cold cache. +- **`#freezeUntransformedSources` writeStageResources**: Only shown when `byPath` reads are needed. Time to write delta resources to CAS and collect metadata. +- **CAS skipped / CAS written**: Shows how many resources had their CAS write skipped because `#knownCasIntegrities` already contained the integrity hash. + +### Phase 4: Cache write + +In CLI builds, cache writes are **deferred** — they run in the background after `Build succeeded` is printed and don't block process exit. In BuildServer mode, cache writes are still awaited. + +``` +info ProjectBuilder Build succeeded in 5.04 s +info ProjectBuilder Executing cleanup tasks... +perf Wrote build cache for project sap.m in 301 ms +``` + +The four sub-operations (stages, requests, sourceIndex, result) run in parallel (`Promise.all`), so wall time ≈ max of all four. When run in the background (CLI), they no longer compete with the build for I/O bandwidth and typically complete faster (~300ms vs ~1400ms when blocking). + +**Important:** The source stage CAS write (`#freezeUntransformedSources`) is NOT part of Phase 4. It runs during `allTasksCompleted()` in Phase 3b, before `Build succeeded` is logged. For sap.m this processes ~11,821 untransformed source resources. Look for the `#freezeUntransformedSources` and `allTasksCompleted #freezeUntransformedSources` log lines — they appear before `Build succeeded`. In the stale-cache scenario this takes ~31ms (metadata reused from previous build via `#cachedFrozenSourceMetadata`). In the cold-cache scenario it takes ~8s because there is no previous metadata and every resource requires `byPath()` + `cacache.get.info()` (see Known Peculiarities #7). + +## UI5_CACHE_PERF Counters + +Setting `UI5_CACHE_PERF=1` enables low-level counters in `utils.js` for `matchResourceMetadataStrict`: + +- `calls` — Total invocations +- `shortCircuitTrue` — Fast-path returns (lastModified matches cached and ≠ indexTimestamp → file unchanged, no I/O needed) +- `sizeMismatch` — Changes detected via size check (cheap) +- `integrityFallback` — Full SHA256 content hash required (expensive) + +These counters appear in the `TreeRegistry.flush` log as `matchMetadataStrictCalls` and `matchMetadataUnchanged`. + +A high `integrityFallback` count means many files have the same size but different content — this is expensive and may indicate that the `lastModified` short-circuit is not working (e.g., due to timestamp resolution issues). + +## Perf Log Reference + +All `log.perf()` statements by source file: + +| File | Log pattern | What it measures | +|------|-------------|------------------| +| `BuildContext.js` | `Parallel source index initialization completed` | Total time for parallel `initSourceIndex` across all projects | +| `BuildContext.js` | `getRequiredProjectContexts completed` | Discovery + index init for all required projects | +| `ProjectBuildContext.js` | `getDependenciesReader completed` | Creating the dependency reader for a project | +| `ProjectBuildContext.js` | `ProjectBuildCache.prepareProjectBuildAndValidateCache completed` | Full cache validation for one project | +| `ProjectBuilder.js` | `getRequiredProjectContexts completed` | Top-level timing (includes context creation) | +| `ProjectBuilder.js` | `prepareProjectBuildAndValidateCache for {project} completed` | Per-project validation with `usesCache` flag | +| `TaskRunner.js` | `Task {name} finished in {N} ms` | Individual task execution time | +| `ProjectBuildCache.js` | `#initSourceIndex fromCacheWithDelta` | Delta detection: resource count, changed count | +| `ProjectBuildCache.js` | `Initialized source index for project` | Total source index init (glob + cache read + delta) | +| `ProjectBuildCache.js` | `Initialized dependency indices` | First-time dependency index refresh | +| `ProjectBuildCache.js` | `Skipping dependency index refresh` | Cached indices reused without refresh | +| `ProjectBuildCache.js` | `Flushed pending changes for project` | Source + dependency index update with pending changes | +| `ProjectBuildCache.js` | `#flushPendingChanges updateSourceIndex` | Source index update only | +| `ProjectBuildCache.js` | `#flushPendingChanges updateDependencyIndices` | Dependency index update with task/path counts | +| `ProjectBuildCache.js` | `#importStages: Initial import` | First-time stage import with suppressed propagation count | +| `ProjectBuildCache.js` | `#findResultCache importStages` | Loading cached stages from CAS | +| `ProjectBuildCache.js` | `#findResultCache restoreFrozenSources` | Creating CAS-backed source reader | +| `ProjectBuildCache.js` | `Validated result cache for project` | Overall result cache validation | +| `ProjectBuildCache.js` | `Updated project indices for task` | Per-task index update during build | +| `ProjectBuildCache.js` | `#writeStageResources for stage` | CAS write with skipped/written counts | +| `ProjectBuildCache.js` | `Wrote build cache for project` | Cache persistence with sub-operation breakdown | +| `ProjectBuildCache.js` | `allTasksCompleted for project` | Total time for allTasksCompleted including revalidation and source freeze | +| `ProjectBuildCache.js` | `allTasksCompleted #revalidateSourceIndex` | Source file revalidation (checks no files changed during build) | +| `ProjectBuildCache.js` | `allTasksCompleted #freezeUntransformedSources` | Writing untransformed source files to CAS | +| `ProjectBuildCache.js` | `#revalidateSourceIndex byGlob` | Re-reading all source files for revalidation | +| `ProjectBuildCache.js` | `#freezeUntransformedSources ... reused all N entries` | Fast path: all metadata reused from previous build | +| `ProjectBuildCache.js` | `#freezeUntransformedSources byPath reads` | Reading only delta untransformed source files | +| `ProjectBuildCache.js` | `#freezeUntransformedSources writeStageResources` | CAS writes during source freeze (delta or cold cache) | +| `ProjectBuildCache.js` | `recordTaskResult for task` | Total time to record task result including merge/signature | +| `ProjectBuildCache.js` | `recordTaskResult delta merge` | Merging previous stage cache with delta results | +| `ProjectBuildCache.js` | `recordTaskResult recordRequests` | Recording resource requests and building hash trees | +| `ResourceRequestManager.js` | `refreshIndices for task` | Full dependency index refresh | +| `ResourceRequestManager.js` | `updateIndices for task ... resource fetch` | Resource fetch phase with cache hit/miss counts | +| `ResourceRequestManager.js` | `#flushTreeChanges for task` | Merkle tree flush with registry count | +| `TreeRegistry.js` | `TreeRegistry.flush completed` | Detailed tree operation breakdown (removals/upserts/rehash) | + +## Known Peculiarities + +### 1. Source index init dominated by byGlob, not delta detection + +For sap.m: `fromCacheWithDelta` takes ~68ms but the total `Initialized source index` takes ~691ms. The remaining ~620ms is `sourceReader.byGlob("/**/*")` which stats all ~12,677 files. The delta detection itself (comparing metadata, hashing only changed files) is fast. + +**Implication:** Source index init performance is I/O-bound (filesystem stat calls), not CPU-bound. Optimizations targeting hash computation won't help much; reducing the number of stat calls would. + +**Validated (2026-04):** Replacing `byGlob("/**/*")` with `fs.readdir(recursive)` + per-file `fs.stat()` using lightweight proxy objects (avoiding Resource construction) was tested and produced a ~300ms *regression* for sap.m. Both approaches must stat every file; globby integrates stat into its directory walk more efficiently than a separate readdir + 12K stat calls. The only way to meaningfully reduce source index init time is to **avoid statting unchanged files entirely** (e.g., via directory mtime heuristics or filesystem change notifications). + +### 2. "cache misses" in resource fetch don't mean cache corruption + +In `updateIndices` logs, "cache misses" means the resource wasn't found in the task's SharedHashTree node cache (a performance optimization for avoiding redundant metadata reads). It does NOT mean the persistent cache is missing. On the first build from cache, all resources are "cache misses" because the in-memory node cache starts empty. + +### 3. Task execution appears twice in the log + +``` +perf Build Task replaceCopyright finished in 1 ms +perf Build Task replaceCopyright finished in 8 ms +``` + +The first line is the task function's execution time. The second includes cache recording overhead (computing signatures, recording resource requests). This is the wall time from the TaskRunner's perspective. + +### 4. writeCache sub-timings all look similar because of Promise.all + +``` +perf Wrote build cache ... (stages=1422 ms, requests=1427 ms, sourceIndex=1395 ms, result=1381 ms) +``` + +These four operations run in parallel. They share I/O bandwidth, so their individual timings overlap. The wall time is ~max of all four, not the sum. To identify the true bottleneck, you'd need to run them sequentially (temporarily modify `writeCache`). + +**Note:** In CLI mode, cache writes are deferred to the background. The `Wrote build cache` log line appears after `Build succeeded`. The sub-timings tend to be lower (~250-300ms vs ~1400ms) because background writes don't compete with the build for I/O bandwidth. + +### 5. matchMetadataUnchanged vs modifiedNodesSkips + +In `TreeRegistry.flush` logs: +- `matchMetadataUnchanged` — Resources whose metadata (lastModified, size, integrity) matched the cached version. No tree modification needed. +- `modifiedNodesSkips` — Tree nodes already marked as modified by a previous flush in the same build. Skipped to avoid redundant work. + +When `matchMetadataUnchanged` equals `matchMetadataStrictCalls`, ALL resources were unchanged — the flush was pure overhead. + +### 6. Dependency index "changed=true" doesn't always mean task re-execution + +The `changed=true` in `#flushPendingChanges updateDependencyIndices` means at least one dependency index signature changed. But the *result* cache validation (`#findResultCache`) may still find a matching cached result if the combined project+dependency signature matches a previous build. + +### 7. `#freezeUntransformedSources` CAS overhead varies dramatically by scenario + +`#freezeUntransformedSources` writes untransformed source files to CAS and collects metadata. Its cost depends on two factors: whether `#knownCasIntegrities` contains the resource integrities (CAS skip), and whether `#cachedFrozenSourceMetadata` contains previous metadata for reuse (delta skip). + +| Scenario | `#knownCasIntegrities` state | `#cachedFrozenSourceMetadata` state | `#freezeUntransformedSources` time (sap.m) | Why | +|----------|------------------------------|--------------------------------------|---------------------------------------------|-----| +| **Stale cache** | Populated from cached source stage | Populated from cached source stage | ~31ms | All entries reused from previous metadata; only JSON write | +| **Cold cache** | Empty (no `indexCache`) | null | ~8s | Every resource: `byPath()` + `cacache.get.info()` + `getBuffer()` + gzip + `cacache.put()` | +| **Warm cache** | N/A (result cache valid, freeze skipped) | N/A | 0 | `#findResultCache` succeeds, `#restoreFrozenSources` used instead | + +**History:** Before the `#knownCasIntegrities` pre-population fix (2026-04), stale-cache builds suffered the ~10s cold-cache penalty because the set was not populated from the source index. The fix populates the set during `#initSourceIndex` from the cached source stage metadata. + +Subsequently, the delta freeze optimization (2026-04) added `#cachedFrozenSourceMetadata`: during `#initSourceIndex`, the previous build's frozen source `resourceMetadata` is retained. In `#freezeUntransformedSources`, untransformed paths found in the previous metadata are reused directly (no `byPath()`, no `getIntegrity()`, no CAS write). Only genuinely new or newly-untransformed files require I/O. For the common stale-cache case (1 file changed), all ~11,821 untransformed entries are reused, reducing freeze time from ~1.2s to ~31ms. + +**Cold-cache CAS write breakdown:** On cold cache, each resource that doesn't exist in CAS incurs: `getIntegrity()` (hash computation), `cacache.get.info()` (index lookup, ~0.8ms), `getBuffer()` (read file), `gzip()` (compression), `cacache.put()` (write). For ~12K resources this totals ~8s. This is a one-time cost since subsequent stale-cache builds reuse frozen metadata entirely. + +### 8. `recordTaskResult` overhead is small for delta builds + +In delta builds, `recordTaskResult()` is called after each task but is fast (~2-15ms per task). The main cost is the delta merge (`reader.byGlob("/**/*")` on the previous stage cache), which scales with the number of resources in the cached stage. For the minify task (2,193 resources), the merge takes ~13ms. + +For full (non-delta) task re-execution, `recordRequests()` is called instead, which is also fast (~0.2ms) because the hash tree building is deferred to the next task's `prepareTaskExecutionAndValidateCache`. + +### 9. Cache writes are fast in background mode + +When cache writes are deferred (CLI mode), `#writeTaskStageCache` + `#writeSourceIndex` + `#writeResultCache` complete in ~60ms total because `#knownCasIntegrities` was populated during `#freezeUntransformedSources`, allowing CAS skip for most resources. + +### 10. `#knownCasIntegrities` population sources + +`#knownCasIntegrities` is a `Set` that tracks integrity hashes known to exist in CAS. When `#writeStageResources` encounters an integrity in this set, it skips the `cacache.get.info()` lookup entirely. The set is populated from three sources: + +1. **Source index cache** (in `#initSourceIndex`): When loading from `indexCache`, all resource integrities from the Merkle tree are added. These correspond to resources written to CAS by the previous build's `#freezeUntransformedSources`. +2. **Imported stage metadata** (in `#importStageMetadata` via `#collectKnownIntegrities`): When restoring cached task stages (e.g., for dependency projects or result cache hits), the resource integrities from stage metadata are added. +3. **Newly written stages** (in `#writeTaskStageCache` / `#freezeUntransformedSources` via `#collectKnownIntegrities`): After writing stage resources, the metadata integrities are added for subsequent stage writes. + +When diagnosing slow `writeStageResources`, check the `CAS skipped` vs `CAS written` counts in the log. If most resources are being written (not skipped), `#knownCasIntegrities` is not being populated from one of these sources — trace which source is missing for the scenario. + +## Investigation Workflow + +1. **Establish a baseline.** Run the build 2-3 times to get stable warm-cache timings. Note the total time and per-phase breakdown. + +2. **Identify the scenario.** Are you investigating warm cache (no changes), stale cache (file edit), or cold cache (no cache at all)? + +3. **Find the dominant phase.** In the perf log, look for the largest times: + - Source index init? → Check `fromCacheWithDelta` vs total to see if it's I/O or hash-bound + - Dependency index flush? → Check "changed paths" count and "cache misses" + - Task execution? → Check which tasks run and whether they support differential builds (◇ vs ›) + - `allTasksCompleted`? → Check `#revalidateSourceIndex` and `#freezeUntransformedSources` sub-timings + - Cache write? → Check the sub-operation breakdown + +4. **Drill down.** For the dominant phase: + - Add more granular `log.perf()` statements if needed + - Use `console.time()`/`console.timeEnd()` for quick local profiling + - Check `matchMetadataStrictCalls` vs `matchMetadataUnchanged` to understand if resources are being unnecessarily re-hashed + +5. **Validate optimization ideas.** When proposing optimizations: + - Consider warm vs stale vs cold cache impact separately + - Check if the optimization target already has an early-exit path (e.g., `ResourceRequestManager.updateIndices` exits early when `requestGraph.getSize() === 0`) + - Measure with the test build before and after + +6. **Watch for measurement artifacts.** The first run after a system boot or after a long idle period will be slower due to OS filesystem cache being cold. Run 2-3 times to get stable readings. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..72248c362e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +UI5 CLI v5 — an open, modular toolchain for developing UI5 framework applications. This is a **monorepo** using npm workspaces containing 6 public packages and 2 internal packages. All code uses **ESM** (`"type": "module"`). + +**Node requirement**: `^22.20.0 || >=24.0.0` + +## Commands + +### Install +```bash +npm ci --engine-strict +``` + +### Run all tests (lint + coverage + license checks + knip) +```bash +npm test +``` + +### Lint +```bash +npm run lint # All workspaces +npm run lint --workspace=@ui5/cli # Single package +``` + +### Unit tests +```bash +npm run unit # All workspaces +npm run unit --workspace=@ui5/builder # Single package +npx ava test/lib/some/test.js # Single test file (run from package dir) +``` + +### Unit tests with verbose logging +```bash +npm run unit-verbose --workspace=@ui5/cli +``` + +### Coverage +```bash +npm run coverage # All workspaces +npm run coverage --workspace=@ui5/server # Single package +``` + +## Architecture + +``` +@ui5/cli CLI entry point (yargs). Commands in lib/cli/commands/ + ├── @ui5/project Project graph, config loading, dependency resolution (AJV schemas) + ├── @ui5/builder Build tasks & processors (JS bundling, minification, CSS, JSDoc) + ├── @ui5/server Express dev server with middleware architecture + ├── @ui5/fs Virtual file system with AbstractReader/AbstractReaderWriter + └── @ui5/logger Logging with configurable levels and progress bars +``` + +- **Entry point**: `packages/cli/bin/ui5.cjs` (CJS wrapper for Node compatibility) → loads `packages/cli/lib/cli/cli.js` (ESM) +- **Builder** uses a task/processor pattern: tasks orchestrate processors, both are loaded from repositories (`taskRepository.js`) +- **Server** uses an Express middleware pipeline; middlewares are loaded dynamically via `MiddlewareManager` +- **FS** provides a resource abstraction layer — `ReaderCollection` composes multiple readers; adapters handle real FS backends +- **Project** builds a `ProjectGraph` of dependencies from npm packages and UI5 config files (YAML/XML); validates configs with AJV JSON schemas + +Internal packages: +- `internal/documentation` — VitePress docs + JSDoc + JSON schema generation +- `internal/shrinkwrap-extractor` — npm shrinkwrap utilities + +## Code Style + +- **Indentation**: tabs +- **Quotes**: double +- **Max line length**: 120 +- **Semicolons**: required +- **No `console.log()`** — use `@ui5/logger` instead +- ESLint flat config with Google style base + JSDoc + AVA plugins + +## Testing + +- **Framework**: AVA with `esmock` for ESM module mocking, `sinon` for stubs/spies +- **Test location**: `packages/*/test/lib/**/*.js` +- **Helpers** (excluded from test runs): `test/**/__helper__/**` +- **Fixtures**: `test/fixtures/`, **Expected outputs**: `test/expected/` +- **Coverage**: NYC (Istanbul) — thresholds vary per package (70-90%) +- **Temp files**: `test/tmp/` (cleaned before each run via `rimraf`) + +## Commit Convention + +Conventional commits enforced via commitlint + husky. Subject must be sentence-case. + +**Types**: `build`, `ci`, `deps`, `docs`, `feat`, `fix`, `perf`, `refactor`, `release`, `revert`, `style`, `test` + +**Scopes** are package names: `builder`, `cli`, `documentation`, `fs`, `logger`, `project`, `server`, `shrinkwrap-extractor`. Some types restrict which scopes are valid (e.g., `feat` and `fix` only allow public package scopes). + +Examples: `feat(builder): Add CSS source map support`, `fix(server): Correct middleware ordering` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..eef4bd20cf9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file From 128a1257403af4e1ab28159ed2bb4e676b9a526c Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 20 Apr 2026 10:07:04 +0200 Subject: [PATCH 206/223] perf(project): Skip redundant CAS lookups for unchanged resources during cache writes Track integrity hashes from restored stage metadata in a Set and skip cacache.get.info() calls for resources already known to be in CAS. Reduces cache write time from ~1,400ms to ~100ms for stale-cache builds by eliminating ~15,000 redundant CAS existence checks. --- .../lib/build/cache/ProjectBuildCache.js | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 17479008c2c..57b57749cb5 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -64,6 +64,10 @@ export default class ProjectBuildCache { #changedDependencyResourcePaths = []; #writtenResultResourcePaths = []; + // Set of integrity hashes known to already exist in CAS from restored stage metadata. + // Populated during the restore phase, consulted during writes to skip redundant CAS lookups. + #knownCasIntegrities = new Set(); + #combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES; #resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; @@ -712,6 +716,8 @@ export default class ProjectBuildCache { stageReader = this.#createReaderForStageCache(stageName, stageSignature, resourceMetadata); } + this.#collectKnownIntegrities(resourceMetadata); + return { signature: stageSignature, stage: stageReader, @@ -996,7 +1002,9 @@ export default class ProjectBuildCache { })); // Write resources to CAS and collect metadata (reuses existing helper) - const resourceMetadata = await this.#writeStageResources(resources, "source", sourceSignature); + const resourceMetadata = await this.#writeStageResources( + resources, "source", sourceSignature, this.#knownCasIntegrities); + this.#collectKnownIntegrities(resourceMetadata); // Persist source stage metadata in the stage cache await this.#cacheManager.writeStageCache( @@ -1032,6 +1040,7 @@ export default class ProjectBuildCache { } const {resourceMetadata} = stageMetadata; + this.#collectKnownIntegrities(resourceMetadata); log.verbose( `Restored frozen source files for project ${this.#project.getName()} from CAS`); @@ -1287,13 +1296,17 @@ export default class ProjectBuildCache { const resourceMetadata = await Promise.all(readers.map(async (reader, idx) => { const resources = await reader.byGlob("/**/*"); - return await this.#writeStageResources(resources, stageId, stageSignature); + return await this.#writeStageResources( + resources, stageId, stageSignature, this.#knownCasIntegrities); })); + this.#collectKnownIntegrities(resourceMetadata); metadata = {resourceMapping, resourceMetadata}; } else { const resources = await writer.byGlob("/**/*"); - const resourceMetadata = await this.#writeStageResources(resources, stageId, stageSignature); + const resourceMetadata = await this.#writeStageResources( + resources, stageId, stageSignature, this.#knownCasIntegrities); + this.#collectKnownIntegrities(resourceMetadata); metadata = {resourceMetadata}; } metadata.projectTagOperations = tagOpsToObject(projectTagOperations); @@ -1303,27 +1316,58 @@ export default class ProjectBuildCache { })); } + /** + * Extracts integrity hashes from resource metadata and adds them to the known CAS set. + * Handles both the array form (WriterCollection stages) and the plain object form. + * + * @param {Object|Array>} resourceMetadata + */ + #collectKnownIntegrities(resourceMetadata) { + const metadataObjects = Array.isArray(resourceMetadata) + ? resourceMetadata : [resourceMetadata]; + for (const metadataObj of metadataObjects) { + for (const meta of Object.values(metadataObj)) { + if (meta.integrity) { + this.#knownCasIntegrities.add(meta.integrity); + } + } + } + } + /** * Writes stage resources to persistent storage and returns their metadata * * @param {@ui5/fs/Resource[]} resources Array of resources to write * @param {string} stageId Stage identifier * @param {string} stageSignature Stage signature + * @param {Set} [knownCasIntegrities] Set of integrity hashes known to exist in CAS * @returns {Promise>} Resource metadata indexed by path */ - async #writeStageResources(resources, stageId, stageSignature) { + async #writeStageResources(resources, stageId, stageSignature, knownCasIntegrities) { const resourceMetadata = Object.create(null); + let casSkipped = 0; await Promise.all(resources.map(async (res) => { - // Store resource content in cacache via CacheManager - await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + const integrity = await res.getIntegrity(); + + // Skip CAS write if integrity is known to already exist from restored stage metadata + if (knownCasIntegrities?.has(integrity)) { + casSkipped++; + } else { + await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + } resourceMetadata[res.getOriginalPath()] = { inode: res.getInode(), lastModified: res.getLastModified(), size: await res.getSize(), - integrity: await res.getIntegrity(), + integrity, }; })); + if (log.isLevelEnabled("perf") && casSkipped > 0) { + log.perf( + `#writeStageResources for stage ${stageId}: ` + + `${casSkipped} CAS skipped, ${resources.length - casSkipped} CAS written`); + } return resourceMetadata; } From ae987bd0306641984f2020a612ba771bb7e3a1d6 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 20 Apr 2026 10:53:33 +0200 Subject: [PATCH 207/223] perf(project): Suppress redundant change propagation on initial stage import On first CLI invocation, #importStages treated all restored stage resources as "changed" because #currentStageSignatures was empty. This caused ~3000+ resource paths to be propagated to dependents, triggering expensive updateDependencyIndices calls (~108ms total). The imported stages represent the already-cached state, not actual changes. Skip writtenResourcePaths accumulation when this is the initial import (empty #currentStageSignatures), since dependents' dependency indices were restored from the same persistent cache. --- .../lib/build/cache/ProjectBuildCache.js | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 57b57749cb5..61810099c39 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -369,6 +369,13 @@ export default class ProjectBuildCache { return [stageName, stageCache]; })); this.#project.getProjectResources().useResultStage(); + + // When #currentStageSignatures is empty, this is the initial import from persistent cache. + // The imported stages represent the already-cached state, not actual changes. + // Dependents' dependency indices were restored from the same cache and already reflect these outputs. + // Skip change propagation to avoid redundant dependency index updates in dependents. + const isInitialImport = this.#currentStageSignatures.size === 0; + const writtenResourcePaths = new Set(); for (const [stageName, stageCache] of importedStages) { // Check whether the stage differs form the one currently in use @@ -380,13 +387,24 @@ export default class ProjectBuildCache { // Store signature for later use in result stage signature calculation this.#currentStageSignatures.set(stageName, stageCache.signature.split("-")); - // Cached stage likely differs from the previous one (if any) - // Add all resources written by the cached stage to the set of written/potentially changed resources - for (const resourcePath of stageCache.writtenResourcePaths) { - writtenResourcePaths.add(resourcePath); + if (!isInitialImport) { + // Cached stage differs from the previous one + // Add all resources written by the cached stage to the set of + // written/potentially changed resources + for (const resourcePath of stageCache.writtenResourcePaths) { + writtenResourcePaths.add(resourcePath); + } } } } + + if (log.isLevelEnabled("perf") && isInitialImport) { + const totalPaths = importedStages.reduce((sum, [, sc]) => sum + sc.writtenResourcePaths.length, 0); + log.perf( + `#importStages: Initial import for project ${this.#project.getName()}, ` + + `suppressed ${totalPaths} resource path propagations`); + } + return Array.from(writtenResourcePaths); } From 718ba886f432b963aaa7657b6c21b587dae90e5d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 20 Apr 2026 11:08:13 +0200 Subject: [PATCH 208/223] perf(project): Skip dependency index refresh when no changes were propagated When restoring from cache, dependency indices are already populated via BuildTaskCache.fromCache. If no dependency changes were propagated from upstream projects, the cached indices are already correct and _refreshDependencyIndices can be skipped entirely. This avoids fetching ~2738 resources per dependent just to confirm nothing changed, saving ~130ms on warm-cache builds. --- .../project/lib/build/cache/ProjectBuildCache.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 61810099c39..ed4088348f9 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -149,12 +149,18 @@ export default class ProjectBuildCache { } if (this.#combinedIndexState === INDEX_STATES.RESTORING_DEPENDENCY_INDICES) { - const updateStart = performance.now(); - await this._refreshDependencyIndices(dependencyReader); - if (log.isLevelEnabled("perf")) { + if (this.#changedDependencyResourcePaths.length) { + const updateStart = performance.now(); + await this._refreshDependencyIndices(dependencyReader); + if (log.isLevelEnabled("perf")) { + log.perf( + `Initialized dependency indices for project ${this.#project.getName()} ` + + `in ${(performance.now() - updateStart).toFixed(2)} ms`); + } + } else if (log.isLevelEnabled("perf")) { log.perf( - `Initialized dependency indices for project ${this.#project.getName()} ` + - `in ${(performance.now() - updateStart).toFixed(2)} ms`); + `Skipping dependency index refresh for project ${this.#project.getName()} ` + + `(no dependency changes propagated)`); } this.#combinedIndexState = INDEX_STATES.FRESH; From 2ce79d8b85141d7cb0652db857c5290efe8446d6 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 22 Apr 2026 14:09:37 +0200 Subject: [PATCH 209/223] perf(project): Add instrumentation for allTasksCompleted, recordTaskResult, and source freeze Add perf-level timing to previously unlogged operations that dominate stale-cache build time: - allTasksCompleted: overall timing + sub-timings for revalidateSourceIndex and freezeUntransformedSources - revalidateSourceIndex: byGlob timing for source file re-read - freezeUntransformedSources: byPath reads and writeStageResources - recordTaskResult: overall timing + delta merge and recordRequests Investigation of a stale-cache sap.m build (1 file changed) showed allTasksCompleted taking 11.3s of a 12s build, with freezeUntransformedSources accounting for 10.2s due to per-resource CAS existence checks. --- .../lib/build/cache/ProjectBuildCache.js | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index ed4088348f9..0a75cd12472 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -773,6 +773,7 @@ export default class ProjectBuildCache { async recordTaskResult( taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo, supportsDifferentialBuilds ) { + const recordStart = performance.now(); if (!this.#taskCache.has(taskName)) { // Initialize task cache this.#taskCache.set(taskName, @@ -825,20 +826,37 @@ export default class ProjectBuildCache { reader = cacheInfo.previousStageCache.stage.getWriter() ?? cacheInfo.previousStageCache.stage.getCachedWriter(); } + const mergeStart = performance.now(); const previousWrittenResources = await reader.byGlob("/**/*"); + let mergedCount = 0; for (const res of previousWrittenResources) { if (!writtenResourcePaths.includes(res.getOriginalPath())) { await stageWriter.write(res); + mergedCount++; } } + if (log.isLevelEnabled("perf")) { + log.perf( + `recordTaskResult delta merge for task ${taskName} ` + + `in project ${this.#project.getName()} completed in ` + + `${(performance.now() - mergeStart).toFixed(2)} ms ` + + `(${previousWrittenResources.length} previous, ${mergedCount} merged)`); + } } else { // Calculate signature for executed task + const recordReqStart = performance.now(); const currentSignaturePair = await taskCache.recordRequests( projectResourceRequests, dependencyResourceRequests, this.#currentProjectReader, this.#currentDependencyReader ); + if (log.isLevelEnabled("perf")) { + log.perf( + `recordTaskResult recordRequests for task ${taskName} ` + + `in project ${this.#project.getName()} completed in ` + + `${(performance.now() - recordReqStart).toFixed(2)} ms`); + } // If provided, set dependency signature for later use in result stage signature calculation const stageName = this.#getStageNameForTask(taskName); this.#currentStageSignatures.set(stageName, currentSignaturePair); @@ -863,6 +881,12 @@ export default class ProjectBuildCache { } // Reset current project reader this.#currentProjectReader = null; + if (log.isLevelEnabled("perf")) { + log.perf( + `recordTaskResult for task ${taskName} in project ${this.#project.getName()} ` + + `completed in ${(performance.now() - recordStart).toFixed(2)} ms ` + + `(${writtenResourcePaths.length} written resources, delta=${!!cacheInfo})`); + } } /** @@ -945,7 +969,14 @@ export default class ProjectBuildCache { */ async #revalidateSourceIndex() { const sourceReader = this.#project.getSourceReader(); + const globStart = performance.now(); const currentResources = await sourceReader.byGlob("/**/*"); + if (log.isLevelEnabled("perf")) { + log.perf( + `#revalidateSourceIndex byGlob for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - globStart).toFixed(2)} ms ` + + `(${currentResources.length} resources)`); + } const tree = this.#sourceIndex.getTree(); const indexedPaths = new Set(this.#sourceIndex.getResourcePaths()); @@ -1015,6 +1046,7 @@ export default class ProjectBuildCache { const sourceSignature = this.#sourceIndex.getSignature(); // Read untransformed source files + const readStart = performance.now(); const resources = await Promise.all(untransformedPaths.map(async (resourcePath) => { const resource = await sourceReader.byPath(resourcePath); if (!resource) { @@ -1024,10 +1056,22 @@ export default class ProjectBuildCache { } return resource; })); + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources byPath reads for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - readStart).toFixed(2)} ms ` + + `(${resources.length} resources)`); + } // Write resources to CAS and collect metadata (reuses existing helper) + const writeStart = performance.now(); const resourceMetadata = await this.#writeStageResources( resources, "source", sourceSignature, this.#knownCasIntegrities); + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources writeStageResources for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - writeStart).toFixed(2)} ms`); + } this.#collectKnownIntegrities(resourceMetadata); // Persist source stage metadata in the stage cache @@ -1086,9 +1130,17 @@ export default class ProjectBuildCache { * @throws {Error} If source files were modified during the build */ async allTasksCompleted() { + const allTasksStart = performance.now(); this.#project.getProjectResources().useResultStage(); + const revalidateStart = performance.now(); const sourceChangedDuringBuild = await this.#revalidateSourceIndex(); + if (log.isLevelEnabled("perf")) { + log.perf( + `allTasksCompleted #revalidateSourceIndex for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - revalidateStart).toFixed(2)} ms ` + + `(changed=${sourceChangedDuringBuild})`); + } if (sourceChangedDuringBuild) { throw new Error( `Detected changes to source files of project ${this.#project.getName()} during the build. ` + @@ -1097,7 +1149,13 @@ export default class ProjectBuildCache { } // Write untransformed source files to CAS for downstream consumer protection + const freezeStart = performance.now(); await this.#freezeUntransformedSources(); + if (log.isLevelEnabled("perf")) { + log.perf( + `allTasksCompleted #freezeUntransformedSources for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - freezeStart).toFixed(2)} ms`); + } if (this.#combinedIndexState === INDEX_STATES.INITIAL) { this.#combinedIndexState = INDEX_STATES.FRESH; @@ -1109,6 +1167,12 @@ export default class ProjectBuildCache { // Reset updated resource paths this.#writtenResultResourcePaths = []; + if (log.isLevelEnabled("perf")) { + log.perf( + `allTasksCompleted for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - allTasksStart).toFixed(2)} ms ` + + `(${changedPaths.length} changed paths)`); + } return changedPaths; } From f7ce9ccc2f6fee6740fc3699050d9dc331691dd6 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 22 Apr 2026 17:10:43 +0200 Subject: [PATCH 210/223] perf(project): Pre-populate known CAS integrities from cached source stage metadata --- .../project/lib/build/cache/ProjectBuildCache.js | 15 +++++++++++++++ .../test/lib/build/cache/ProjectBuildCache.js | 10 +++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 0a75cd12472..2cc8c79e6f1 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -1212,6 +1212,21 @@ export default class ProjectBuildCache { const {resourceIndex, changedPaths} = await ResourceIndex.fromCacheWithDelta(indexCache, resources, Date.now()); + // Pre-populate knownCasIntegrities from the previous build's frozen source stage. + // The source stage metadata records which resources were actually written to CAS + // by the previous build's #freezeUntransformedSources. Using this instead of the + // source index tree ensures we only skip CAS writes for resources that genuinely + // exist in CAS (the tree may contain integrities from newly added or modified files + // that were never written to CAS). + const cachedSourceSignature = indexCache.indexTree.root.hash; + if (cachedSourceSignature) { + const sourceStageMetadata = await this.#cacheManager.readStageCache( + this.#project.getId(), this.#buildSignature, "source", cachedSourceSignature); + if (sourceStageMetadata?.resourceMetadata) { + this.#collectKnownIntegrities(sourceStageMetadata.resourceMetadata); + } + } + // Import task caches const buildTaskCaches = await Promise.all( indexCache.tasks.map(async ([taskName, supportsDifferentialBuilds]) => { diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 8a9ec18e08a..911c7e4fc95 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -985,12 +985,16 @@ test("restoreFrozenSources: cache hit creates CAS reader", async (t) => { t.truthy(result, "Cache restored successfully"); // Verify readStageCache was called with "source" stageId and the correct signature + // Two calls expected: first from #initSourceIndex (pre-populating knownCasIntegrities using the + // cached tree's root hash), second from #restoreFrozenSources (using the result cache's source signature) const sourceReadCalls = cacheManager.readStageCache.getCalls().filter( (call) => call.args[2] === "source" ); - t.true(sourceReadCalls.length > 0, "readStageCache was called for source stage"); - t.is(sourceReadCalls[0].args[3], "source-sig-456", - "readStageCache called with correct source signature"); + t.is(sourceReadCalls.length, 2, "readStageCache was called twice for source stage"); + t.is(sourceReadCalls[0].args[3], "hash-a", + "First readStageCache call uses cached tree root hash for knownCasIntegrities pre-population"); + t.is(sourceReadCalls[1].args[3], "source-sig-456", + "Second readStageCache call uses correct source signature for frozen source restore"); // Verify setFrozenSourceReader was called on project resources after restore const projectResources = project.getProjectResources(); From 954bff4c89c9a15a009fdbf8fc8af41d67e9d6cc Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 22 Apr 2026 16:05:16 +0200 Subject: [PATCH 211/223] perf(project): Reuse previous frozen source metadata for unchanged files In #freezeUntransformedSources, retain the previous build's frozen source resourceMetadata and reuse entries for untransformed paths that haven't changed, avoiding byPath reads and metadata collection for ~12K resources. For sap.m stale-cache builds (1 file changed), this reduces #freezeUntransformedSources from ~1,212ms to ~31ms. --- .../lib/build/cache/ProjectBuildCache.js | 122 +++++++--- .../test/lib/build/cache/ProjectBuildCache.js | 216 ++++++++++++++++++ 2 files changed, 312 insertions(+), 26 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 2cc8c79e6f1..f3aeb1e5723 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -68,6 +68,11 @@ export default class ProjectBuildCache { // Populated during the restore phase, consulted during writes to skip redundant CAS lookups. #knownCasIntegrities = new Set(); + // Previous build's frozen source resourceMetadata, loaded from cache during #initSourceIndex(). + // Used in #freezeUntransformedSources() to skip re-reading unchanged untransformed source files. + // Updated after each freeze for reuse in subsequent BuildServer builds. + #cachedFrozenSourceMetadata = null; + #combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES; #resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; @@ -1039,39 +1044,101 @@ export default class ProjectBuildCache { if (untransformedPaths.length === 0) { log.verbose( `All source files of project ${this.#project.getName()} are overlayed by build tasks`); + this.#cachedFrozenSourceMetadata = null; return; } - const sourceReader = this.#project.getSourceReader(); const sourceSignature = this.#sourceIndex.getSignature(); + const previousMetadata = this.#cachedFrozenSourceMetadata; + + let resourceMetadata; + if (previousMetadata) { + // Delta path: reuse previous metadata for unchanged untransformed paths + const pathsToRead = []; + const reusedMetadata = Object.create(null); + for (const p of untransformedPaths) { + if (previousMetadata[p]) { + reusedMetadata[p] = previousMetadata[p]; + } else { + pathsToRead.push(p); + } + } - // Read untransformed source files - const readStart = performance.now(); - const resources = await Promise.all(untransformedPaths.map(async (resourcePath) => { - const resource = await sourceReader.byPath(resourcePath); - if (!resource) { - throw new Error( - `Source file ${resourcePath} not found during CAS freeze ` + - `for project ${this.#project.getName()}`); + if (pathsToRead.length === 0) { + // Fast path: all metadata reused from previous build + resourceMetadata = reusedMetadata; + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources for project ${this.#project.getName()}: ` + + `reused all ${untransformedPaths.length} entries from previous metadata`); + } + } else { + // Delta path: read only new/newly-untransformed paths + const sourceReader = this.#project.getSourceReader(); + const readStart = log.isLevelEnabled("perf") ? performance.now() : 0; + const resources = await Promise.all(pathsToRead.map(async (resourcePath) => { + const resource = await sourceReader.byPath(resourcePath); + if (!resource) { + throw new Error( + `Source file ${resourcePath} not found during CAS freeze ` + + `for project ${this.#project.getName()}`); + } + return resource; + })); + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources byPath reads for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - readStart).toFixed(2)} ms ` + + `(${resources.length} of ${untransformedPaths.length} resources)`); + } + + const writeStart = log.isLevelEnabled("perf") ? performance.now() : 0; + const deltaMetadata = await this.#writeStageResources( + resources, "source", sourceSignature, this.#knownCasIntegrities); + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources writeStageResources for project ` + + `${this.#project.getName()} ` + + `completed in ${(performance.now() - writeStart).toFixed(2)} ms`); + } + + // Merge: reused entries + delta entries + resourceMetadata = reusedMetadata; + for (const [path, meta] of Object.entries(deltaMetadata)) { + resourceMetadata[path] = meta; + } + } + } else { + // Cold cache: read all untransformed sources (no previous metadata available) + const sourceReader = this.#project.getSourceReader(); + const readStart = log.isLevelEnabled("perf") ? performance.now() : 0; + const resources = await Promise.all(untransformedPaths.map(async (resourcePath) => { + const resource = await sourceReader.byPath(resourcePath); + if (!resource) { + throw new Error( + `Source file ${resourcePath} not found during CAS freeze ` + + `for project ${this.#project.getName()}`); + } + return resource; + })); + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources byPath reads for project ${this.#project.getName()} ` + + `completed in ${(performance.now() - readStart).toFixed(2)} ms ` + + `(${resources.length} resources)`); } - return resource; - })); - if (log.isLevelEnabled("perf")) { - log.perf( - `#freezeUntransformedSources byPath reads for project ${this.#project.getName()} ` + - `completed in ${(performance.now() - readStart).toFixed(2)} ms ` + - `(${resources.length} resources)`); - } - // Write resources to CAS and collect metadata (reuses existing helper) - const writeStart = performance.now(); - const resourceMetadata = await this.#writeStageResources( - resources, "source", sourceSignature, this.#knownCasIntegrities); - if (log.isLevelEnabled("perf")) { - log.perf( - `#freezeUntransformedSources writeStageResources for project ${this.#project.getName()} ` + - `completed in ${(performance.now() - writeStart).toFixed(2)} ms`); + const writeStart = log.isLevelEnabled("perf") ? performance.now() : 0; + resourceMetadata = await this.#writeStageResources( + resources, "source", sourceSignature, this.#knownCasIntegrities); + if (log.isLevelEnabled("perf")) { + log.perf( + `#freezeUntransformedSources writeStageResources for project ` + + `${this.#project.getName()} ` + + `completed in ${(performance.now() - writeStart).toFixed(2)} ms`); + } } + this.#collectKnownIntegrities(resourceMetadata); // Persist source stage metadata in the stage cache @@ -1085,8 +1152,10 @@ export default class ProjectBuildCache { // Create CAS-backed proxy reader for the untransformed source files const casSourceReader = this.#createReaderForStageCache("source", sourceSignature, resourceMetadata); - this.#project.getProjectResources().setFrozenSourceReader(casSourceReader); + + // Retain for potential reuse in subsequent BuildServer builds + this.#cachedFrozenSourceMetadata = resourceMetadata; } /** @@ -1224,6 +1293,7 @@ export default class ProjectBuildCache { this.#project.getId(), this.#buildSignature, "source", cachedSourceSignature); if (sourceStageMetadata?.resourceMetadata) { this.#collectKnownIntegrities(sourceStageMetadata.resourceMetadata); + this.#cachedFrozenSourceMetadata = sourceStageMetadata.resourceMetadata; } } diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 911c7e4fc95..2138975f741 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -800,6 +800,222 @@ test("freezeUntransformedSources: throws when source file not found", async (t) "Error message mentions CAS freeze"); }); +// ===== DELTA FREEZE REUSE TESTS ===== + +/** + * Helper that creates a ProjectBuildCache from a warm cache (with index cache and previous + * frozen source metadata), runs a single task, and returns the cache + mocks for assertions. + * + * @param {object} options + * @param {Array} options.sourceResources Resources returned by the source reader + * @param {string[]} options.taskWrittenPaths Paths written by the task + * @param {object} options.previousFrozenMetadata Previous build's frozen source resourceMetadata + * @param {string} [options.cachedSourceSignature="prev-source-sig"] Signature from the cached index tree root + */ +async function buildCacheWithWarmCacheAndTaskResult({ + sourceResources, taskWrittenPaths, previousFrozenMetadata, cachedSourceSignature = "prev-source-sig" +}) { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-sig"; + + // Source reader returns given resources for byGlob and individual byPath + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves(sourceResources), + byPath: sinon.stub().callsFake((path) => { + const res = sourceResources.find((r) => r.getPath() === path); + return Promise.resolve(res || null); + }) + })); + + // Build an indexCache that matches the source resources with no changes detected + const children = {}; + for (const res of sourceResources) { + const p = res.getPath(); + const name = p.slice(1); // strip leading / + const integrity = await res.getIntegrity(); + const size = await res.getSize(); + children[name] = { + name, + type: "resource", + hash: `node-hash-${name}`, + integrity, + lastModified: res.getLastModified(), + size, + inode: res.getInode(), + tags: null + }; + } + + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 500, // Earlier than resource lastModified (1000) so matchMetadata says unchanged + root: { + name: "", + type: "directory", + hash: cachedSourceSignature, + children + } + }, + tasks: [["myTask", 0]] + }; + + // Mock task metadata for the cached task + cacheManager.readTaskMetadata.callsFake((projectId, buildSig, taskName, type) => { + return Promise.resolve({ + requestSetGraph: {nodes: [], nextId: 1}, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false + }); + }); + + // Return previous frozen source metadata when readStageCache is called + // with the cached source signature during initSourceIndex + cacheManager.readStageCache.callsFake((projectId, buildSig, stageId, stageSignature) => { + if (stageId === "source" && stageSignature === cachedSourceSignature && previousFrozenMetadata) { + return Promise.resolve({resourceMetadata: previousFrozenMetadata}); + } + return Promise.resolve(null); + }); + + cacheManager.readIndexCache.resolves(indexCache); + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + await cache.initSourceIndex(); + + // Set up and execute a task + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + // Simulate task writing some resources + const writtenResources = taskWrittenPaths.map( + (p) => createMockResource(p, `hash-${p}`, 2000, 200, 2) + ); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves(writtenResources) + }) + }); + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, null, false); + + return {cache, project, cacheManager}; +} + +test("freezeUntransformedSources: fast path — reuses all entries from previous metadata", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + const resC = createMockResource("/c.js", "hash-c", 1000, 100, 3); + const resD = createMockResource("/d.js", "hash-d", 1000, 100, 4); + + // Previous frozen metadata covers /c.js and /d.js (the untransformed ones) + const previousFrozenMetadata = { + "/c.js": {integrity: "hash-c", lastModified: 1000, size: 100, inode: 3}, + "/d.js": {integrity: "hash-d", lastModified: 1000, size: 100, inode: 4}, + }; + + // Task writes /a.js and /b.js — so /c.js and /d.js are untransformed + const {cache, cacheManager} = await buildCacheWithWarmCacheAndTaskResult({ + sourceResources: [resA, resB, resC, resD], + taskWrittenPaths: ["/a.js", "/b.js"], + previousFrozenMetadata, + }); + + await cache.allTasksCompleted(); + + // writeStageResource should NOT be called — all metadata was reused + t.is(cacheManager.writeStageResource.callCount, 0, + "No CAS writes needed when all metadata is reused"); + + // writeStageCache SHOULD be called with the reused metadata + const sourceStageCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + t.is(sourceStageCalls.length, 1, "writeStageCache called once for source stage"); + + const writtenMetadata = sourceStageCalls[0].args[4].resourceMetadata; + t.truthy(writtenMetadata["/c.js"], "Reused metadata for /c.js"); + t.truthy(writtenMetadata["/d.js"], "Reused metadata for /d.js"); + t.falsy(writtenMetadata["/a.js"], "No metadata for transformed /a.js"); + t.falsy(writtenMetadata["/b.js"], "No metadata for transformed /b.js"); + t.is(writtenMetadata["/c.js"].integrity, "hash-c", "Correct integrity for /c.js"); +}); + +test("freezeUntransformedSources: delta path — only reads new files missing from previous metadata", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + const resC = createMockResource("/c.js", "hash-c", 1000, 100, 3); + const resD = createMockResource("/d.js", "hash-d", 1000, 100, 4); + const resE = createMockResource("/e.js", "hash-e", 1000, 100, 5); + + // Previous frozen metadata only covers /c.js and /d.js + // /e.js is a newly added file (not in previous metadata) + const previousFrozenMetadata = { + "/c.js": {integrity: "hash-c", lastModified: 1000, size: 100, inode: 3}, + "/d.js": {integrity: "hash-d", lastModified: 1000, size: 100, inode: 4}, + }; + + // Task writes /a.js and /b.js — so /c.js, /d.js, /e.js are untransformed + const {cache, cacheManager} = await buildCacheWithWarmCacheAndTaskResult({ + sourceResources: [resA, resB, resC, resD, resE], + taskWrittenPaths: ["/a.js", "/b.js"], + previousFrozenMetadata, + }); + + await cache.allTasksCompleted(); + + // writeStageResource should be called only for /e.js (the new file) + const stageResourceCalls = cacheManager.writeStageResource.getCalls(); + const writtenPaths = stageResourceCalls.map((call) => call.args[3].getOriginalPath()); + t.is(writtenPaths.length, 1, "Only 1 CAS write for the new file"); + t.true(writtenPaths.includes("/e.js"), "New file /e.js written to CAS"); + + // Merged metadata should contain all 3 untransformed files + const sourceStageCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + const writtenMetadata = sourceStageCalls[0].args[4].resourceMetadata; + t.truthy(writtenMetadata["/c.js"], "Reused metadata for /c.js"); + t.truthy(writtenMetadata["/d.js"], "Reused metadata for /d.js"); + t.truthy(writtenMetadata["/e.js"], "New metadata for /e.js"); + t.is(writtenMetadata["/c.js"].integrity, "hash-c", "Correct reused integrity for /c.js"); + t.is(writtenMetadata["/e.js"].integrity, "hash-e", "Correct new integrity for /e.js"); +}); + +test("freezeUntransformedSources: removed file excluded from reused metadata", async (t) => { + const resA = createMockResource("/a.js", "hash-a", 1000, 100, 1); + const resC = createMockResource("/c.js", "hash-c", 1000, 100, 3); + + // Previous frozen metadata contains /c.js and /removed.js + // /removed.js no longer exists in source index + const previousFrozenMetadata = { + "/c.js": {integrity: "hash-c", lastModified: 1000, size: 100, inode: 3}, + "/removed.js": {integrity: "hash-removed", lastModified: 1000, size: 100, inode: 9}, + }; + + // Task writes /a.js — /c.js is untransformed. /removed.js is gone from sources. + const {cache, cacheManager} = await buildCacheWithWarmCacheAndTaskResult({ + sourceResources: [resA, resC], + taskWrittenPaths: ["/a.js"], + previousFrozenMetadata, + }); + + await cache.allTasksCompleted(); + + const sourceStageCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "source" + ); + const writtenMetadata = sourceStageCalls[0].args[4].resourceMetadata; + t.truthy(writtenMetadata["/c.js"], "Reused metadata for /c.js"); + t.falsy(writtenMetadata["/removed.js"], "Removed file NOT in metadata"); +}); + // ===== RESULT METADATA SHAPE TESTS ===== test("writeResultCache: metadata includes sourceStageSignature", async (t) => { From 4928143514f8ebab919dbd985ee4ac7189248c8b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 22 Apr 2026 16:27:35 +0200 Subject: [PATCH 212/223] refactor(server): Skip bundling by default --- packages/server/lib/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index b4768d595e6..668318f41c8 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -174,7 +174,7 @@ export async function serve(graph, { } const buildServer = await graph.serve({ initialBuildIncludedDependencies, - excludedTasks: ["minify"], + excludedTasks: ["minify", "generateLibraryPreload", "generateComponentPreload", "generateBundle"], }); const resources = { From 90d98be18e9d0a340806dbed49a34b5caecb10de Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 22 Apr 2026 18:17:04 +0200 Subject: [PATCH 213/223] test(project): Add tests for dependency index refresh skip optimization Cover the RESTORING_DEPENDENCY_INDICES state handling in prepareProjectBuildAndValidateCache, verifying that: - _refreshDependencyIndices is skipped when no dependency changes were propagated (warm cache scenario) - dependencyResourcesChanged() moves state to REQUIRES_UPDATE, routing changes through #flushPendingChanges instead - State correctly transitions from RESTORING_DEPENDENCY_INDICES to FRESH after the first prepareProjectBuildAndValidateCache call - Subsequent dependency changes use the REQUIRES_UPDATE path --- .../test/lib/build/cache/ProjectBuildCache.js | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 2138975f741..726e3c00551 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -1219,3 +1219,148 @@ test("restoreFrozenSources: cache hit creates CAS reader", async (t) => { t.truthy(projectResources.setFrozenSourceReader.firstCall.args[0], "setFrozenSourceReader called with a reader"); }); + +// ===== DEPENDENCY INDEX REFRESH OPTIMIZATION TESTS ===== + +// Helper: Creates a ProjectBuildCache in RESTORING_DEPENDENCY_INDICES state +// (warm cache loaded from disk with task metadata) and spies on _refreshDependencyIndices. +async function createCacheInRestoringState({ + resources = [createMockResource("/test.js", "hash1", 1000, 100, 1)], + tasks = [["task1", false]], +} = {}) { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const buildSignature = "test-signature"; + + project.getSourceReader.callsFake(() => ({ + byGlob: sinon.stub().resolves(resources), + byPath: sinon.stub().callsFake((path) => { + const res = resources.find((r) => r.getPath() === path); + return Promise.resolve(res || null); + }) + })); + + // Build an indexCache matching the resources (no changes detected) + const children = {}; + for (const res of resources) { + const p = res.getPath(); + const name = p.slice(1); + children[name] = { + name, + type: "resource", + hash: `node-hash-${name}`, + integrity: await res.getIntegrity(), + lastModified: res.getLastModified(), + size: await res.getSize(), + inode: res.getInode(), + tags: null + }; + } + + const indexCache = { + version: "1.0", + indexTree: { + version: 1, + indexTimestamp: 500, // Earlier than resource lastModified so matchMetadata says unchanged + root: { + name: "", + type: "directory", + hash: "root-hash", + children + } + }, + tasks + }; + + cacheManager.readTaskMetadata.callsFake((projectId, buildSig, taskName, type) => { + return Promise.resolve({ + requestSetGraph: {nodes: [], nextId: 1}, + rootIndices: [], + deltaIndices: [], + unusedAtLeastOnce: false + }); + }); + + cacheManager.readIndexCache.resolves(indexCache); + + const cache = await ProjectBuildCache.create(project, buildSignature, cacheManager); + await cache.initSourceIndex(); + + // Spy on _refreshDependencyIndices so we can verify whether it's called + const refreshSpy = sinon.spy(cache, "_refreshDependencyIndices"); + + const mockDependencyReader = { + byGlob: sinon.stub().resolves([]), + byPath: sinon.stub().resolves(null) + }; + + return {cache, project, cacheManager, refreshSpy, mockDependencyReader}; +} + +test("prepareProjectBuildAndValidateCache: skips _refreshDependencyIndices when no dependency " + + "changes propagated (warm cache)", async (t) => { + const {cache, refreshSpy, mockDependencyReader} = await createCacheInRestoringState(); + + // Do NOT call dependencyResourcesChanged — simulates warm cache with no upstream changes. + // In RESTORING_DEPENDENCY_INDICES state, cached dependency indices (from BuildTaskCache.fromCache) + // are already correct, so _refreshDependencyIndices can be skipped. + await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + + t.false(refreshSpy.called, + "_refreshDependencyIndices should NOT be called when no dependency changes were propagated"); +}); + +test("prepareProjectBuildAndValidateCache: dependency changes move state from " + + "RESTORING_DEPENDENCY_INDICES to REQUIRES_UPDATE", async (t) => { + const {cache, refreshSpy, mockDependencyReader} = await createCacheInRestoringState(); + + // Simulate an upstream dependency rebuild propagating changed paths. + // dependencyResourcesChanged() transitions state from RESTORING_DEPENDENCY_INDICES to REQUIRES_UPDATE, + // so the changes go through #flushPendingChanges (incremental delta update) rather than + // the full _refreshDependencyIndices path. + cache.dependencyResourcesChanged(["/dep/lib/SomeModule.js"]); + + await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + + // _refreshDependencyIndices is NOT called because dependencyResourcesChanged() already moved + // the state to REQUIRES_UPDATE, bypassing the RESTORING_DEPENDENCY_INDICES branch entirely. + // Instead, #flushPendingChanges handles the changes via incremental updateDependencyIndices. + t.false(refreshSpy.called, + "_refreshDependencyIndices should NOT be called — changes handled via #flushPendingChanges"); +}); + +test("prepareProjectBuildAndValidateCache: transitions from RESTORING_DEPENDENCY_INDICES " + + "to FRESH state", async (t) => { + const {cache, refreshSpy, mockDependencyReader} = await createCacheInRestoringState(); + + // First call transitions from RESTORING_DEPENDENCY_INDICES to FRESH + await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + t.false(refreshSpy.called, "_refreshDependencyIndices not called on first pass (no changes)"); + + // Second call without new changes — state is already FRESH, no refresh needed + refreshSpy.resetHistory(); + await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + + t.false(refreshSpy.called, + "_refreshDependencyIndices should NOT be called on second invocation (state is FRESH)"); +}); + +test("prepareProjectBuildAndValidateCache: subsequent dependency changes go through " + + "REQUIRES_UPDATE path, not RESTORING_DEPENDENCY_INDICES", async (t) => { + const {cache, refreshSpy, mockDependencyReader} = await createCacheInRestoringState(); + + // First call with no changes — transitions from RESTORING_DEPENDENCY_INDICES to FRESH + await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + t.false(refreshSpy.called, "_refreshDependencyIndices not called on first pass (no changes)"); + + // Now simulate a dependency change (e.g. from BuildServer watch mode) + cache.dependencyResourcesChanged(["/dep/lib/NewChange.js"]); + + // Second call — should go through REQUIRES_UPDATE / #flushPendingChanges, not _refreshDependencyIndices + refreshSpy.resetHistory(); + await cache.prepareProjectBuildAndValidateCache(mockDependencyReader); + + t.false(refreshSpy.called, + "_refreshDependencyIndices should NOT be called for REQUIRES_UPDATE state " + + "(#flushPendingChanges handles it instead)"); +}); From 6423a598bb47c653f4f544a6c54765c1ca143bd7 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Apr 2026 10:40:53 +0200 Subject: [PATCH 214/223] docs(fs): Add skill for @ui5/fs package Add skill with architecture reference covering class hierarchy, Resource content model, adapter internals, collection patterns, and resourceFactory API. --- .claude/skills/ui5-fs/SKILL.md | 67 +++++ .claude/skills/ui5-fs/architecture.md | 381 ++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 .claude/skills/ui5-fs/SKILL.md create mode 100644 .claude/skills/ui5-fs/architecture.md diff --git a/.claude/skills/ui5-fs/SKILL.md b/.claude/skills/ui5-fs/SKILL.md new file mode 100644 index 00000000000..24baf6f3a23 --- /dev/null +++ b/.claude/skills/ui5-fs/SKILL.md @@ -0,0 +1,67 @@ +--- +name: ui5-fs +description: > + Work on the @ui5/fs package: the virtual file system abstraction layer providing + Resource, Adapters (FileSystem, Memory), Reader Collections (ReaderCollection, + ReaderCollectionPrioritized, DuplexCollection, WriterCollection), specialized readers + (Filter, Link, Proxy), resource tagging, monitoring, and the resourceFactory API. +when_to_use: > + TRIGGER when: the user asks about or wants to modify code related to @ui5/fs, + the virtual file system, resources, adapters, reader collections, writer collections, + DuplexCollection, resourceFactory, ResourceTagCollection, MonitoredReader, fsInterface, + ResourceFacade, or any file within packages/fs/. + DO NOT TRIGGER when: the user is working on builder tasks/processors, CLI commands, + server middleware, or project graph resolution that does not touch the FS layer. +user-invocable: true +--- + +# @ui5/fs Skill + +You are working on the `@ui5/fs` package — the virtual file system abstraction layer for UI5 CLI. Read `architecture.md` in this skill directory for the full architecture reference, including the class hierarchy, API surface, adapter internals, and collection patterns. + +## Package Location + +Source: `packages/fs/lib/` +Tests: `packages/fs/test/lib/` +Fixtures: `packages/fs/test/fixtures/` + +## Guidelines for Working on This Code + +1. **Always read the source file before modifying it.** The Component Map in `architecture.md` tells you where each piece lives. +2. **Understand the content type state machine.** Resources have internal content types (BUFFER, STREAM, FACTORY, DRAINED_STREAM, IN_TRANSFORMATION) with strict transitions. Never bypass `modifyStream()` for content transformations — it handles mutex locking and state management. +3. **Respect the mutex.** Resource content access is protected by `async-mutex`. Concurrent `getBuffer()` / `getString()` calls are safe, but `modifyStream()` acquires an exclusive lock. Never hold a reference to content across an `await` that could trigger a transformation. +4. **Virtual paths are POSIX-absolute.** All virtual paths must be absolute POSIX paths (start with `/`). Base paths must end with `/`. Adapters normalize patterns relative to their `virBasePath`. +5. **Content parameters are mutually exclusive.** When creating a Resource, only one of `buffer`, `string`, `stream`, `createStream`, or `createBuffer` can be provided. The `createBuffer`/`createStream` factories enable lazy loading. +6. **byGlob randomizes result order.** `AbstractReader.byGlob()` intentionally shuffles results to prevent consumers from relying on ordering. Do not assume or depend on glob result order. +7. **Clone semantics matter.** Resources are cloned on retrieval from adapters. `resource.clone()` creates an independent copy including content. When modifying resources from collections, understand whether you're working on the original or a clone. +8. **ResourceFacade is immutable.** `ResourceFacade.setPath()` throws. The facade wraps a resource with a different virtual path (used by Link reader). Use `getOriginalPath()` to get the underlying path. +9. **Tag format is strict.** Tags follow the pattern `"namespace:Name"` — namespace is lowercase alphanumeric, name is PascalCase. Tags are validated on set. Use `ResourceTagCollection` or `MonitoredResourceTagCollection` for tag operations. +10. **Test with both FileSystem and Memory adapters.** Behavior can differ between adapters (e.g., FileSystem uses `globby` while Memory uses `micromatch`; FileSystem has lazy content loading via factories, Memory clones on read). + +## Known Constraints + +- `Resource.getStream()` is deprecated — use `getStreamAsync()` or `getBuffer()` / `getString()` instead. The deprecation warning is only logged once per process. +- `Resource.getStatInfo()` is deprecated — use `getLastModified()`, `getSize()`, `getInode()` instead. +- FileSystem adapter's `write()` uses `fs.copyFile` for unmodified resources (optimization). It also detects same-source-same-target writes and skips them, but switches to buffer-based writes if the content was modified (to avoid stream read-during-write conflicts). +- Memory adapter auto-creates virtual directory entries in its hierarchy on `write()`. +- `WriterCollection` matches the **longest prefix** (greedy match) when routing writes to writers. +- `DuplexCollection` uses an internal `ReaderCollectionPrioritized` with the writer first, so written resources shadow the reader's resources. +- `fsInterface` provides a Node.js `fs`-compatible wrapper but only implements `readFile`, `stat`, and `readdir`. `mkdir` is a no-op. +- `Resource.getIntegrity()` computes SHA-256 via `ssri` — this triggers full content loading if not already loaded. +- Monitored readers/writers (`MonitoredReader`, `MonitoredReaderWriter`) prevent reads/writes after being sealed. They track all access patterns for build caching analysis. + +## Running Tests + +```bash +# All tests +npm run unit --workspace=@ui5/fs + +# Single file +cd packages/fs && npx ava test/lib/Resource.js + +# Verbose with logging +npm run unit-verbose --workspace=@ui5/fs + +# Coverage +npm run coverage --workspace=@ui5/fs +``` diff --git a/.claude/skills/ui5-fs/architecture.md b/.claude/skills/ui5-fs/architecture.md new file mode 100644 index 00000000000..84d9aa92b7e --- /dev/null +++ b/.claude/skills/ui5-fs/architecture.md @@ -0,0 +1,381 @@ +# @ui5/fs Architecture Reference + +## Component Map + +| Component | File | Purpose | +|-----------|------|---------| +| **AbstractReader** | `lib/AbstractReader.js` | Abstract base for all readers. Defines `byPath()`, `byGlob()` | +| **AbstractReaderWriter** | `lib/AbstractReaderWriter.js` | Extends AbstractReader with `write()` | +| **Resource** | `lib/Resource.js` | Core file representation: content + metadata | +| **ResourceFacade** | `lib/ResourceFacade.js` | Wraps Resource with remapped virtual path | +| **ResourceTagCollection** | `lib/ResourceTagCollection.js` | Tag storage and validation (`"namespace:Name"`) | +| **MonitoredResourceTagCollection** | `lib/MonitoredResourceTagCollection.js` | Wraps ResourceTagCollection with access tracking | +| **MonitoredReader** | `lib/MonitoredReader.js` | Wraps reader with access tracking (seals after use) | +| **MonitoredReaderWriter** | `lib/MonitoredReaderWriter.js` | Wraps reader/writer with access tracking | +| **ReaderCollection** | `lib/ReaderCollection.js` | Parallel multi-reader aggregation | +| **ReaderCollectionPrioritized** | `lib/ReaderCollectionPrioritized.js` | Sequential prioritized multi-reader | +| **DuplexCollection** | `lib/DuplexCollection.js` | Combined reader + writer (writer shadows reader) | +| **WriterCollection** | `lib/WriterCollection.js` | Path-prefix-routed multi-writer | +| **FileSystem adapter** | `lib/adapters/FileSystem.js` | Real filesystem adapter (globby, graceful-fs) | +| **Memory adapter** | `lib/adapters/Memory.js` | In-memory virtual adapter (micromatch) | +| **AbstractAdapter** | `lib/adapters/AbstractAdapter.js` | Base adapter: virBasePath, excludes, path normalization | +| **Filter reader** | `lib/readers/Filter.js` | Callback-based resource filtering | +| **Link reader** | `lib/readers/Link.js` | Virtual path remapping (returns ResourceFacade) | +| **Proxy reader** | `lib/readers/Proxy.js` | Custom getResource/listResourcePaths callbacks | +| **resourceFactory** | `lib/resourceFactory.js` | Factory functions for all components | +| **fsInterface** | `lib/fsInterface.js` | Node.js fs-compatible wrapper over readers | +| **Trace** | `lib/tracing/Trace.js` | Performance tracing (active at log level "silly") | +| **traceSummary** | `lib/tracing/traceSummary.js` | Aggregates trace reports | + +## Class Hierarchy + +``` +AbstractReader +├── AbstractReaderWriter +│ ├── AbstractAdapter +│ │ ├── adapters/FileSystem +│ │ └── adapters/Memory +│ ├── DuplexCollection +│ ├── WriterCollection +│ └── MonitoredReaderWriter +├── ReaderCollection +├── ReaderCollectionPrioritized +├── readers/Filter +├── readers/Link +├── readers/Proxy +└── MonitoredReader + +Resource (standalone) +ResourceFacade (wraps Resource) +ResourceTagCollection (standalone) +MonitoredResourceTagCollection (wraps ResourceTagCollection) +``` + +## Resource Content Model + +### Content Types (internal state machine) + +``` +FACTORY ──getBuffer()/getString()/getStreamAsync()──> BUFFER +BUFFER ──setStream()──> STREAM +STREAM ──getBuffer()/getString()──> BUFFER (consumes stream) +BUFFER ──setBuffer()/setString()──> BUFFER +* ──modifyStream()──> IN_TRANSFORMATION ──callback done──> BUFFER +* ──write({drain:true})──> DRAINED_STREAM +``` + +- **FACTORY**: Lazy — content created on demand via `createBuffer`/`createStream` factories +- **BUFFER**: Content fully materialized in memory +- **STREAM**: Readable stream (single-consume; converted to BUFFER on read) +- **DRAINED_STREAM**: Content was released after write; further reads throw +- **IN_TRANSFORMATION**: Locked during `modifyStream()` callback + +### Content Creation (mutually exclusive parameters) + +```javascript +new Resource({ + path: "/resources/my/File.js", // Required, absolute POSIX + buffer: Buffer.from("..."), // OR + string: "...", // OR + stream: readableStream, // OR + createBuffer: async () => buf, // OR + createStream: async () => stream // (factories for lazy loading) +}); +``` + +### Content Access + +| Method | Returns | Notes | +|--------|---------|-------| +| `getBuffer()` | `Promise` | Materializes content if needed | +| `getString()` | `Promise` | UTF-8 decoded buffer | +| `getStreamAsync()` | `Promise` | Preferred over deprecated `getStream()` | +| `getStream()` | `stream.Readable` | **Deprecated** — sync, logs warning once | +| `setBuffer(buf)` | — | Marks resource as modified | +| `setString(str)` | — | Marks resource as modified | +| `setStream(stream\|fn)` | — | Accepts stream or factory function | +| `modifyStream(cb)` | `Promise` | Acquires mutex; `cb(stream) => stream\|Buffer` | + +### Metadata + +| Method | Returns | Notes | +|--------|---------|-------| +| `getPath()` | `string` | Absolute POSIX virtual path | +| `setPath(path)` | — | Updates path (throws on ResourceFacade) | +| `getOriginalPath()` | `string` | For ResourceFacade: underlying path | +| `getName()` | `string` | Basename of path | +| `isDirectory()` | `boolean` | — | +| `getLastModified()` | `number` | ms since epoch | +| `getInode()` | `number` | Filesystem inode | +| `getSize()` | `Promise` | Byte size (triggers content load) | +| `hasSize()` | `boolean` | True if size known without content load | +| `getIntegrity()` | `Promise` | SHA-256 via ssri (triggers content load) | +| `isModified()` | `boolean` | True if content changed since creation | +| `getProject()` | `object` | Associated @ui5/project | +| `getSourceMetadata()` | `object` | `{adapter, fsPath, contentModified}` | +| `clone()` | `Promise` | Independent deep copy | +| `getTags()` | `ResourceTagCollection` | Tag operations for this resource | +| `pushCollection(name)` | — | Track retrieval collection (tracing) | + +## Adapter Internals + +### AbstractAdapter + +Base class for FileSystem and Memory adapters. Extends `AbstractReaderWriter`. + +**Constructor params:** `{name, virBasePath, excludes, project}` + +**Key internal methods:** +- `_isPathHandled(virPath)` — checks if path falls under `virBasePath` +- `_isPathExcluded(virPath)` — checks if path matches exclude patterns +- `_resolveVirtualPathToBase(virPath, writeMode)` — strips `virBasePath` prefix, returns relative path +- `_normalizePattern(virPattern)` — makes glob patterns relative to adapter base +- `_createResource(params)` — creates Resource with project and source metadata injected + +### FileSystem Adapter + +Maps a `virBasePath` to a `fsBasePath` on the real filesystem. + +**Additional constructor params:** `{fsBasePath, useGitignore}` + +**Glob:** Uses `globby` with `gitignore` support. Glob patterns are resolved relative to `fsBasePath`. + +**Content loading:** Uses factory functions for lazy loading: +```javascript +createStream: () => createReadStream(fsPath) +createBuffer: () => readFile(fsPath) +``` + +**Write optimization:** +1. Unmodified resource with same source adapter → `fs.copyFile` (fast path) +2. Source path === target path and content unmodified → skip write entirely +3. Source path === target path but content modified → read to buffer first (avoids read-during-write) +4. Otherwise → pipe stream to write stream + +**Read-only:** When `write({readOnly: true})`, sets `chmod 0o444` after writing. + +### Memory Adapter + +Virtual in-memory storage. No filesystem access. + +**Internal data structures:** +- `_virFiles` — Map of virtual path → Resource +- `_virDirs` — Map of virtual path → Resource (directory entries) + +**Glob:** Uses `micromatch` against keys of `_virFiles`/`_virDirs`. + +**Write:** Clones resource, auto-creates parent directory hierarchy entries. + +**Read:** Returns clones of stored resources (never the originals). + +## Collection Patterns + +### ReaderCollection (parallel) + +```javascript +// byGlob: Promise.all across all readers, concat results +// byPath: Promise.all, return first non-null +``` + +Use when sources are independent and results should be aggregated. + +### ReaderCollectionPrioritized (sequential priority) + +```javascript +// byGlob: runs all readers, deduplicates by path (first reader wins) +// byPath: tries readers in order, returns first match +``` + +Use when one source should shadow another (e.g., local overrides). + +### DuplexCollection (reader + writer) + +Internally creates `ReaderCollectionPrioritized([writer, reader])`: +- Reads check writer first (written resources shadow originals) +- Writes go to the writer adapter + +**Primary use case:** Build workspace — write intermediate results to memory, which then take priority over source files on read. + +### WriterCollection (path-routed writes) + +Routes `write()` calls based on longest-matching path prefix: +```javascript +new WriterCollection({ + writerMapping: { + "/": defaultWriter, + "/resources/my-lib/": libWriter, // matches /resources/my-lib/** + } +}); +``` + +For reading, creates an internal `ReaderCollection` from all unique writers. + +## Specialized Readers + +### Filter + +Wraps a reader with a predicate callback: +```javascript +new Filter({ + reader: sourceReader, + callback: (resource) => !resource.getPath().endsWith(".map") +}); +``` +- `byGlob()` filters results after reader returns them +- `byPath()` returns null if callback rejects the resource + +### Link + +Remaps virtual paths between `linkPath` and `targetPath`: +```javascript +new Link({ + reader: sourceReader, + pathMapping: { linkPath: "/app", targetPath: "/resources/my-app/" } +}); +``` +- Returns `ResourceFacade` instances with remapped paths +- `byPath("/app/Component.js")` → reads `/resources/my-app/Component.js` from source reader +- `byGlob("/app/**")` → resolves patterns against target path, returns facades + +### Proxy + +Generic callback-based reader for custom resource sources: +```javascript +new Proxy({ + name: "my-proxy", + getResource: async (virPath) => resource, + listResourcePaths: async () => ["/path/a.js", "/path/b.js"] +}); +``` + +## resourceFactory API + +The primary entry point for creating @ui5/fs components. + +| Function | Returns | Purpose | +|----------|---------|---------| +| `createAdapter({name, virBasePath, fsBasePath?, ...})` | FileSystem or Memory adapter | FileSystem if `fsBasePath` given, else Memory | +| `createReader({fsBasePath, virBasePath, ...})` | ReaderCollection | FS adapter wrapped in ReaderCollection | +| `createReaderCollection({name, readers})` | ReaderCollection | Parallel collection | +| `createReaderCollectionPrioritized({name, readers})` | ReaderCollectionPrioritized | Priority collection | +| `createWriterCollection({name, writerMapping})` | WriterCollection | Path-routed writer | +| `createWorkspace({reader, writer?, virBasePath?, name?})` | DuplexCollection | Build workspace pattern | +| `createResource(params)` | Resource | Resource instance | +| `createFilterReader({reader, callback})` | Filter | Filtered reader | +| `createLinkReader({reader, pathMapping})` | Link | Path-remapping reader | +| `createFlatReader({name, reader, namespace})` | Link | Shorthand: maps `/` → `/resources//` | +| `createProxy({name, getResource, listResourcePaths})` | Proxy | Custom source reader | +| `createMonitor(readerWriter)` | MonitoredReaderWriter | Access-tracking wrapper | +| `prefixGlobPattern(virPattern, virBaseDir)` | string | Prepend base directory to glob pattern | + +## Monitoring System + +Used by the incremental build system to track what resources were accessed during a build task. + +### MonitoredReader / MonitoredReaderWriter + +Wraps a reader/writer and records all `byPath()`, `byGlob()`, and `write()` calls. + +```javascript +const monitored = createMonitor(adapter); +// ... perform operations ... +const requests = monitored.getResourceRequests(); +// { paths: ["/resources/a.js"], patterns: ["**/*.xml"] } +``` + +After sealing, further access throws an error. + +### MonitoredResourceTagCollection + +Wraps `ResourceTagCollection` and tracks all `setTag()`, `getTag()`, `clearTag()` calls. + +```javascript +const operations = monitoredTags.getTagOperations(); +// Map { "/resources/a.js" => { "build:IsDebug": true } } +``` + +## Tag System + +Tags follow the format `"namespace:Name"`: +- **Namespace**: lowercase alphanumeric, starts with letter (e.g., `build`, `theme`) +- **Name**: PascalCase alphanumeric (e.g., `IsDebugVariant`, `IsPartOfBuild`) +- **Values**: `string | number | boolean` (default: `true`) + +Tags are stored per-resource-path in `ResourceTagCollection._pathTags`. + +Allowed tags/namespaces are validated on `setTag()`. Tags can be initialized from a serialized object via the `tags` constructor parameter. + +## Public Exports + +```javascript +import AbstractReader from "@ui5/fs/AbstractReader"; +import AbstractReaderWriter from "@ui5/fs/AbstractReaderWriter"; +import DuplexCollection from "@ui5/fs/DuplexCollection"; +import ReaderCollection from "@ui5/fs/ReaderCollection"; +import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; +import Resource from "@ui5/fs/Resource"; +import fsInterface from "@ui5/fs/fsInterface"; +import {createAdapter, createResource, ...} from "@ui5/fs/resourceFactory"; +import FileSystem from "@ui5/fs/adapters/FileSystem"; +import Memory from "@ui5/fs/adapters/Memory"; +import Filter from "@ui5/fs/readers/Filter"; +import Link from "@ui5/fs/readers/Link"; +import Proxy from "@ui5/fs/readers/Proxy"; + +// Internal (not part of public API contract): +import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; +import MonitoredResourceTagCollection from "@ui5/fs/internal/MonitoredResourceTagCollection"; +``` + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `@ui5/logger` | Logging (`getLogger("fs:Resource")`, etc.) | +| `async-mutex` | Mutex for concurrent content access in Resource | +| `clone` | Deep cloning resources in Memory adapter | +| `globby` | File globbing in FileSystem adapter | +| `graceful-fs` | Robust fs operations in FileSystem adapter | +| `micromatch` | Pattern matching in Memory adapter | +| `minimatch` | Glob parsing in resourceFactory | +| `ssri` | SHA-256 integrity hashing in Resource | +| `pretty-hrtime` | Human-readable time in Trace | +| `random-int` | Result order randomization in AbstractReader | + +## Test Structure + +Tests mirror the lib structure under `packages/fs/test/lib/`: + +``` +test/ +├── lib/ +│ ├── AbstractReader.js +│ ├── AbstractReaderWriter.js +│ ├── Resource.js +│ ├── ResourceFacade.js +│ ├── ResourceTagCollection.js +│ ├── MonitoredReader.js +│ ├── MonitoredReaderWriter.js +│ ├── MonitoredResourceTagCollection.js +│ ├── ReaderCollection.js +│ ├── ReaderCollectionPrioritized.js +│ ├── DuplexCollection.js +│ ├── WriterCollection.js +│ ├── fsInterface.js +│ ├── resourceFactory.js +│ ├── adapters/ +│ │ ├── AbstractAdapter.js +│ │ ├── FileSystem.js +│ │ ├── FileSystem_write.js +│ │ └── Memory.js +│ ├── readers/ +│ │ ├── Filter.js +│ │ ├── Link.js +│ │ └── Proxy.js +│ └── tracing/ +│ └── Trace.js +└── fixtures/ + └── ... +``` + +Framework: AVA with `esmock` for ESM module mocking and `sinon` for stubs/spies. From 4b97c8ce0d5d8b5b589d3d788dbad17dff319a8e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Apr 2026 11:31:24 +0200 Subject: [PATCH 215/223] perf(project): Replace cacache with custom CAS for faster dist writes Replace cacache's content-addressable storage with a lightweight custom implementation that computes content paths synchronously from integrity hashes, eliminating ~5 seconds of cacache.get.info() index lookups per build when writing ~14k resources to dist. Key changes: - New ContentAddressableStorage class with synchronous contentPath() computation, gzip-compressed storage, and atomic writes - CacheManager now delegates to CAS module instead of cacache - ProjectBuildCache resolves CAS paths synchronously (no PassThrough bridge needed in createStream factory) - CAS-backed resources get sourceMetadata {adapter: "CAS"} to prepare for future write-path optimizations - Bump CACHE_VERSION to v0_3 (breaking: old caches are invalidated) BREAKING CHANGE: Build cache version bumped from v0_2 to v0_3. Existing build caches will be rebuilt on first run. --- package-lock.json | 4 +- .../project/lib/build/cache/CacheManager.js | 82 ++++----- .../build/cache/ContentAddressableStorage.js | 142 +++++++++++++++ .../lib/build/cache/ProjectBuildCache.js | 43 +---- packages/project/package.json | 2 +- .../build/cache/ContentAddressableStorage.js | 171 ++++++++++++++++++ .../test/lib/build/cache/ProjectBuildCache.js | 7 +- 7 files changed, 364 insertions(+), 87 deletions(-) create mode 100644 packages/project/lib/build/cache/ContentAddressableStorage.js create mode 100644 packages/project/test/lib/build/cache/ContentAddressableStorage.js diff --git a/package-lock.json b/package-lock.json index 90009c87421..00f2bcf5d52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14019,6 +14019,8 @@ }, "node_modules/ssri": { "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -16089,7 +16091,6 @@ "@ui5/logger": "^5.0.0-alpha.4", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", - "cacache": "^20.0.3", "chalk": "^5.6.2", "chokidar": "^3.6.0", "escape-string-regexp": "^5.0.0", @@ -16106,6 +16107,7 @@ "read-pkg": "^10.0.0", "resolve": "^1.22.10", "semver": "^7.7.2", + "ssri": "^13.0.1", "xml2js": "^0.6.2", "yesno": "^0.4.0" }, diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 0647a62ade4..50f9b7a3275 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,8 +1,6 @@ -import cacache from "cacache"; import path from "node:path"; import fs from "graceful-fs"; import {promisify} from "node:util"; -import {gzip} from "node:zlib"; const mkdir = promisify(fs.mkdir); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -10,23 +8,22 @@ import os from "node:os"; import Configuration from "../../config/Configuration.js"; import {getPathFromPackageName} from "../../utils/sanitizeFileName.js"; import {getLogger} from "@ui5/logger"; +import ContentAddressableStorage from "./ContentAddressableStorage.js"; const log = getLogger("build:cache:CacheManager"); // Singleton instances mapped by cache directory path const chacheManagerInstances = new Map(); -// Options for cacache operations (using SHA-256 for integrity checks) -const CACACHE_OPTIONS = {algorithms: ["sha256"]}; - // Cache version for compatibility management -const CACHE_VERSION = "v0_2"; +const CACHE_VERSION = "v0_3"; /** - * Manages persistence for the build cache using file-based storage and cacache + * Manages persistence for the build cache using file-based storage and a + * content-addressable storage (CAS) for resource content * * CacheManager provides a hierarchical file-based cache structure: - * - cas/ - Content-addressable storage (cacache) for resource content + * - cas/ - Content-addressable storage for resource content (gzip-compressed) * - buildManifests/ - Build manifest files containing metadata about builds * - stageMetadata/ - Stage-level metadata organized by project, build, and stage * - index/ - Resource index files for efficient change detection @@ -38,15 +35,15 @@ const CACHE_VERSION = "v0_2"; * 4. Stage signature (hash of input resources) * * Key features: - * - Content-addressable storage with integrity verification + * - Content-addressable storage with synchronous path resolution * - Singleton pattern per cache directory * - Configurable cache location via UI5_DATA_DIR or configuration - * - Efficient resource deduplication through cacache + * - Efficient resource deduplication through content-addressable storage * * @class */ export default class CacheManager { - #casDir; + #cas; #manifestDir; #stageMetadataDir; #taskMetadataDir; @@ -64,7 +61,7 @@ export default class CacheManager { */ constructor(cacheDir) { cacheDir = path.join(cacheDir, CACHE_VERSION); - this.#casDir = path.join(cacheDir, "cas"); + this.#cas = new ContentAddressableStorage(path.join(cacheDir, "cas")); this.#manifestDir = path.join(cacheDir, "buildManifests"); this.#stageMetadataDir = path.join(cacheDir, "stageMetadata"); this.#taskMetadataDir = path.join(cacheDir, "taskMetadata"); @@ -419,65 +416,52 @@ export default class CacheManager { await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); } + /** + * Computes the filesystem path for a CAS resource given its integrity hash + * + * This is synchronous — no I/O is performed. + * + * @public + * @param {string} integrity SRI integrity string (e.g., "sha256-...") + * @returns {string} Absolute filesystem path to the cached content file + */ + contentPath(integrity) { + return this.#cas.contentPath(integrity); + } + /** * Retrieves the file system path for a cached resource * - * Looks up a resource in the content-addressable storage using its cache key - * and verifies its integrity. If integrity mismatches, attempts to recover by - * looking up the content by digest and updating the index. + * Computes the content path from the integrity hash and verifies the file exists. * * @public - * @param {string} buildSignature Build signature hash - * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature Stage signature hash - * @param {string} resourcePath Virtual path of the resource * @param {string} integrity Expected integrity hash (e.g., "sha256-...") * @returns {Promise} Absolute path to the cached resource file, or null if not found * @throws {Error} If integrity is not provided */ - async getResourcePathForStage(buildSignature, stageId, stageSignature, resourcePath, integrity) { + async getResourcePathForStage(integrity) { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } - // const cacheKey = this.#createKeyForStage(buildSignature, stageId, stageSignature, resourcePath, integrity); - const result = await cacache.get.info(this.#casDir, integrity); - if (!result) { - return null; + if (await this.#cas.has(integrity)) { + return this.#cas.contentPath(integrity); } - return result.path; + return null; } /** - * Writes a resource to the cache for a specific stage - * - * If the resource content (identified by integrity hash) already exists in the - * content-addressable storage, only updates the index with a new cache key. - * Otherwise, writes the full content to storage. + * Writes a resource to the content-addressable storage * - * This enables efficient deduplication when the same resource content appears - * in multiple stages or builds. + * If the resource content (identified by integrity hash) already exists, + * the write is skipped (deduplication). * * @public - * @param {string} buildSignature Build signature hash - * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature Stage signature hash * @param {@ui5/fs/Resource} resource Resource to cache * @returns {Promise} */ - async writeStageResource(buildSignature, stageId, stageSignature, resource) { - // Check if resource has already been written + async writeStageResource(resource) { const integrity = await resource.getIntegrity(); - const hasResource = await cacache.get.info(this.#casDir, integrity); - if (!hasResource) { - const buffer = await resource.getBuffer(); - // Compress the buffer using gzip before caching - const compressedBuffer = await promisify(gzip)(buffer); - await cacache.put( - this.#casDir, - integrity, - compressedBuffer, - CACACHE_OPTIONS - ); - } + const buffer = await resource.getBuffer(); + await this.#cas.put(integrity, buffer); } } diff --git a/packages/project/lib/build/cache/ContentAddressableStorage.js b/packages/project/lib/build/cache/ContentAddressableStorage.js new file mode 100644 index 00000000000..c874064fd2a --- /dev/null +++ b/packages/project/lib/build/cache/ContentAddressableStorage.js @@ -0,0 +1,142 @@ +import ssri from "ssri"; +import path from "node:path"; +import fs from "graceful-fs"; +import {promisify} from "node:util"; +import {gzip, gunzip, createGunzip} from "node:zlib"; +const mkdir = promisify(fs.mkdir); +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); +const rename = promisify(fs.rename); +const access = promisify(fs.access); +const unlink = promisify(fs.unlink); + +let tmpCounter = 0; + +/** + * Content-addressable storage for build cache resources + * + * Stores gzip-compressed content keyed by the original resource integrity hash. + * The filesystem path is a pure function of the integrity hash, enabling + * synchronous path resolution without index lookups. + * + * Directory structure: + * {basePath}/{algorithm}/{xx}/{yy}/{rest} + * + * For example, integrity "sha256-abc123..." with hex digest "abcdef0123456789..." becomes: + * {basePath}/sha256/ab/cd/ef0123456789... + * + * @class + */ +export default class ContentAddressableStorage { + #basePath; + + /** + * @param {string} basePath Base directory for content storage + */ + constructor(basePath) { + this.#basePath = basePath; + } + + /** + * Computes the filesystem path for a given integrity hash + * + * This is a synchronous, pure function with no I/O. + * + * @param {string} integrity SRI integrity string (e.g., "sha256-base64encoded=") + * @returns {string} Absolute filesystem path to the content file + */ + contentPath(integrity) { + const sri = ssri.parse(integrity, {single: true}); + const hex = sri.hexDigest(); + return path.join( + this.#basePath, + sri.algorithm, + hex.slice(0, 2), + hex.slice(2, 4), + hex.slice(4) + ); + } + + /** + * Checks whether content with the given integrity exists in storage + * + * @param {string} integrity SRI integrity string + * @returns {Promise} True if content exists + */ + async has(integrity) { + try { + await access(this.contentPath(integrity)); + return true; + } catch { + return false; + } + } + + /** + * Stores resource content in the CAS + * + * Compresses the buffer with gzip and writes it atomically (tmp + rename). + * Deduplicates: skips write if content with the same integrity already exists. + * + * @param {string} integrity SRI integrity string of the uncompressed content + * @param {Buffer} buffer Uncompressed resource content + * @returns {Promise} + */ + async put(integrity, buffer) { + const contentPath = this.contentPath(integrity); + + // Dedup: skip if content already exists + if (await this.has(integrity)) { + return; + } + + const compressedBuffer = await promisify(gzip)(buffer); + const dirPath = path.dirname(contentPath); + await mkdir(dirPath, {recursive: true}); + + // Atomic write: write to temp file then rename. + // Use a unique counter to avoid collisions between concurrent puts. + const tmpPath = contentPath + `.tmp.${process.pid}.${tmpCounter++}`; + try { + await writeFile(tmpPath, compressedBuffer); + await rename(tmpPath, contentPath); + } catch (err) { + // Clean up tmp file on failure (best effort) + try { + await unlink(tmpPath); + } catch { + // tmp file already gone (e.g., concurrent rename succeeded first) + } + // If the content now exists (written by a concurrent put), that's fine + if (await this.has(integrity)) { + return; + } + throw err; + } + } + + /** + * Creates a readable stream that decompresses content from the CAS + * + * This is synchronous — the stream is returned immediately. + * + * @param {string} integrity SRI integrity string + * @returns {import("node:stream").Readable} Decompressed content stream + */ + createReadStream(integrity) { + const contentPath = this.contentPath(integrity); + return fs.createReadStream(contentPath).pipe(createGunzip()); + } + + /** + * Reads and decompresses content from the CAS + * + * @param {string} integrity SRI integrity string + * @returns {Promise} Decompressed content buffer + */ + async readContent(integrity) { + const contentPath = this.contentPath(integrity); + const compressedBuffer = await readFile(contentPath); + return await promisify(gunzip)(compressedBuffer); + } +} diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index f3aeb1e5723..a7bc4d884ab 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -3,7 +3,6 @@ import {getLogger} from "@ui5/logger"; import fs from "graceful-fs"; import {promisify} from "node:util"; import crypto from "node:crypto"; -import {PassThrough} from "node:stream"; import {gunzip, createGunzip} from "node:zlib"; const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; @@ -1526,7 +1525,7 @@ export default class ProjectBuildCache { if (knownCasIntegrities?.has(integrity)) { casSkipped++; } else { - await this.#cacheManager.writeStageResource(this.#buildSignature, stageId, stageSignature, res); + await this.#cacheManager.writeStageResource(res); } resourceMetadata[res.getOriginalPath()] = { @@ -1621,43 +1620,21 @@ export default class ProjectBuildCache { `in project ${this.#project.getName()}`); } - // Lazily resolve the cache path on first content access. - // This avoids cacache.get.info() I/O during index updates where - // only metadata (lastModified, size, integrity, inode) is needed. - let cachePathPromise; - const resolveCachePath = () => { - if (!cachePathPromise) { - cachePathPromise = this.#cacheManager.getResourcePathForStage( - this.#buildSignature, stageId, stageSignature, virPath, integrity - ).then((cachePath) => { - if (!cachePath) { - throw new Error( - `Unexpected cache miss for resource ${virPath} of task ${stageId} ` + - `in project ${this.#project.getName()}`); - } - return cachePath; - }); - } - return cachePathPromise; - }; + // Compute the CAS content path synchronously from the integrity hash. + // No I/O needed — the path is a pure function of the integrity. + const cachePath = this.#cacheManager.contentPath(integrity); return createResource({ path: virPath, + sourceMetadata: { + adapter: "CAS", + fsPath: cachePath, + contentModified: false, + }, createStream: () => { - // createStream must return a stream synchronously. - // Use a PassThrough as a bridge to defer the async cache path resolution. - const passThrough = new PassThrough(); - resolveCachePath().then((cachePath) => { - const src = fs.createReadStream(cachePath).pipe(createGunzip()); - src.pipe(passThrough); - src.on("error", (err) => passThrough.destroy(err)); - }).catch((err) => { - passThrough.destroy(err); - }); - return passThrough; + return fs.createReadStream(cachePath).pipe(createGunzip()); }, createBuffer: async () => { - const cachePath = await resolveCachePath(); const compressedBuffer = await readFile(cachePath); return await promisify(gunzip)(compressedBuffer); }, diff --git a/packages/project/package.json b/packages/project/package.json index e0dec9180dd..b571af7fc37 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -61,7 +61,6 @@ "@ui5/logger": "^5.0.0-alpha.4", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", - "cacache": "^20.0.3", "chalk": "^5.6.2", "chokidar": "^3.6.0", "escape-string-regexp": "^5.0.0", @@ -78,6 +77,7 @@ "read-pkg": "^10.0.0", "resolve": "^1.22.10", "semver": "^7.7.2", + "ssri": "^13.0.1", "xml2js": "^0.6.2", "yesno": "^0.4.0" }, diff --git a/packages/project/test/lib/build/cache/ContentAddressableStorage.js b/packages/project/test/lib/build/cache/ContentAddressableStorage.js new file mode 100644 index 00000000000..13867641d2a --- /dev/null +++ b/packages/project/test/lib/build/cache/ContentAddressableStorage.js @@ -0,0 +1,171 @@ +import test from "ava"; +import path from "node:path"; +import fs from "graceful-fs"; +import {promisify} from "node:util"; +import {gunzip} from "node:zlib"; +import {rimraf} from "rimraf"; +import ContentAddressableStorage from "../../../../lib/build/cache/ContentAddressableStorage.js"; + +const readFile = promisify(fs.readFile); + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "ContentAddressableStorage"); + +test.beforeEach(async (t) => { + t.context.basePath = path.join(TEST_DIR, `cas-${Date.now()}-${Math.random().toString(36).slice(2)}`); + t.context.cas = new ContentAddressableStorage(t.context.basePath); +}); + +test.after.always(async () => { + await rimraf(TEST_DIR); +}); + +test("contentPath: Computes deterministic path from integrity", (t) => { + const cas = t.context.cas; + + // Use a known integrity hash + const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + const result = cas.contentPath(integrity); + + t.true(result.startsWith(t.context.basePath)); + t.true(result.includes("sha256")); + + // Verify determinism: same input produces same output + t.is(result, cas.contentPath(integrity)); +}); + +test("contentPath: Path contains algorithm and hex digest segments", (t) => { + const cas = t.context.cas; + const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + const result = cas.contentPath(integrity); + + // Path structure: {basePath}/sha256/{xx}/{yy}/{rest} + const relPath = path.relative(t.context.basePath, result); + const parts = relPath.split(path.sep); + + t.is(parts[0], "sha256"); + t.is(parts[1].length, 2); // First 2 hex chars + t.is(parts[2].length, 2); // Next 2 hex chars + t.true(parts[3].length > 0); // Remaining hex chars +}); + +test("contentPath: Different integrities produce different paths", (t) => { + const cas = t.context.cas; + const integrity1 = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + const integrity2 = "sha256-LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564="; + + t.not(cas.contentPath(integrity1), cas.contentPath(integrity2)); +}); + +test("has: Returns false when content does not exist", async (t) => { + const cas = t.context.cas; + const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + + t.false(await cas.has(integrity)); +}); + +test("has: Returns true after content is stored", async (t) => { + const cas = t.context.cas; + const content = Buffer.from("test content"); + const integrity = "sha256-6DvEUQhlMraqbfGBGR2fhsJaNhr0K0VhMupLPEYrmIY="; + + // Compute real integrity for test content + const ssri = await import("ssri"); + const realIntegrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); + + await cas.put(realIntegrity, content); + t.true(await cas.has(realIntegrity)); +}); + +test("put: Stores gzip-compressed content at correct path", async (t) => { + const cas = t.context.cas; + const content = Buffer.from("hello world"); + + const ssri = await import("ssri"); + const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); + + await cas.put(integrity, content); + + // Verify file exists at computed path + const contentPath = cas.contentPath(integrity); + const compressedData = await readFile(contentPath); + + // Verify it's gzip-compressed: decompress and compare + const decompressed = await promisify(gunzip)(compressedData); + t.deepEqual(decompressed, content); +}); + +test("put: Deduplicates — skips write if content already exists", async (t) => { + const cas = t.context.cas; + const content = Buffer.from("deduplicated content"); + + const ssri = await import("ssri"); + const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); + + await cas.put(integrity, content); + const contentPath = cas.contentPath(integrity); + const stat1 = fs.statSync(contentPath); + + // Small delay to ensure mtime would differ if rewritten + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Write same content again + await cas.put(integrity, content); + const stat2 = fs.statSync(contentPath); + + // File should not have been rewritten (mtime unchanged) + t.is(stat1.mtimeMs, stat2.mtimeMs); +}); + +test("createReadStream: Returns decompressed content stream", async (t) => { + const cas = t.context.cas; + const content = Buffer.from("stream test content"); + + const ssri = await import("ssri"); + const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); + + await cas.put(integrity, content); + + const stream = cas.createReadStream(integrity); + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + const result = Buffer.concat(chunks); + t.deepEqual(result, content); +}); + +test("readContent: Returns decompressed buffer", async (t) => { + const cas = t.context.cas; + const content = Buffer.from("buffer test content"); + + const ssri = await import("ssri"); + const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); + + await cas.put(integrity, content); + + const result = await cas.readContent(integrity); + t.deepEqual(result, content); +}); + +test("readContent: Throws when content does not exist", async (t) => { + const cas = t.context.cas; + const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + + await t.throwsAsync(cas.readContent(integrity), {code: "ENOENT"}); +}); + +test("put + readContent: Roundtrip with large content", async (t) => { + const cas = t.context.cas; + // Create a 1MB buffer with random-ish content + const content = Buffer.alloc(1024 * 1024); + for (let i = 0; i < content.length; i++) { + content[i] = i % 256; + } + + const ssri = await import("ssri"); + const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); + + await cas.put(integrity, content); + const result = await cas.readContent(integrity); + t.deepEqual(result, content); +}); diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 726e3c00551..47927c8c0d7 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -65,7 +65,8 @@ function createMockCacheManager() { readTaskMetadata: sinon.stub().resolves(null), writeTaskMetadata: sinon.stub().resolves(), writeStageResource: sinon.stub().resolves(), - getResourcePathForStage: sinon.stub().resolves("/fake/cache/path") + getResourcePathForStage: sinon.stub().resolves("/fake/cache/path"), + contentPath: sinon.stub().returns("/fake/cas/content/path") }; } @@ -692,7 +693,7 @@ test("freezeUntransformedSources: writes only untransformed source files to CAS" // writeStageResource should be called for untransformed files /c.js and /d.js const stageResourceCalls = cacheManager.writeStageResource.getCalls(); - const writtenPaths = stageResourceCalls.map((call) => call.args[3].getOriginalPath()); + const writtenPaths = stageResourceCalls.map((call) => call.args[0].getOriginalPath()); t.true(writtenPaths.includes("/c.js"), "Untransformed /c.js written to CAS"); t.true(writtenPaths.includes("/d.js"), "Untransformed /d.js written to CAS"); t.false(writtenPaths.includes("/a.js"), "Transformed /a.js NOT written to CAS by freeze"); @@ -972,7 +973,7 @@ test("freezeUntransformedSources: delta path — only reads new files missing fr // writeStageResource should be called only for /e.js (the new file) const stageResourceCalls = cacheManager.writeStageResource.getCalls(); - const writtenPaths = stageResourceCalls.map((call) => call.args[3].getOriginalPath()); + const writtenPaths = stageResourceCalls.map((call) => call.args[0].getOriginalPath()); t.is(writtenPaths.length, 1, "Only 1 CAS write for the new file"); t.true(writtenPaths.includes("/e.js"), "New file /e.js written to CAS"); From 2febb1ed04ce91581bdc4e7fd69ecbb1501587f2 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Apr 2026 11:47:58 +0200 Subject: [PATCH 216/223] docs(project): Update incremental build skill --- .../skills/incremental-build/architecture.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index a3143161e61..fe69b380288 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -41,7 +41,8 @@ Use this table to locate source files. ALWAYS read the relevant source file befo | `ProjectBuildCache` | `lib/build/cache/ProjectBuildCache.js` | Cache orchestration per project: index management, stage lookup, result recording | | `BuildTaskCache` | `lib/build/cache/BuildTaskCache.js` | Per-task resource request tracking and index management | | `StageCache` | `lib/build/cache/StageCache.js` | In-memory cache of stage results keyed by signature | -| `CacheManager` | `lib/build/cache/CacheManager.js` | Persistent cache I/O (filesystem) | +| `ContentAddressableStorage` | `lib/build/cache/ContentAddressableStorage.js` | Custom CAS with synchronous path resolution from integrity hash | +| `CacheManager` | `lib/build/cache/CacheManager.js` | Persistent cache I/O (filesystem), delegates content storage to CAS | | `ResourceRequestManager` | `lib/build/cache/ResourceRequestManager.js` | Request graph, resource index updates, signature computation | | `ResourceRequestGraph` | `lib/build/cache/ResourceRequestGraph.js` | DAG of request sets with delta encoding and best-parent optimization | | `ResourceIndex` | `lib/build/cache/index/ResourceIndex.js` | Wrapper around hash trees with delta detection | @@ -102,8 +103,8 @@ Note: There is no separate `BUILDING` state; `INVALIDATED` covers both "needs bu | signature -> {stage, writtenPaths, tagOps} | +---------------------------------------------+ | Persistent (CacheManager) | <- Across sessions -| ~/.ui5/buildCache/v0_2/ | -| cas/ (content-addressable, gz) | +| ~/.ui5/buildCache/v0_3/ | +| cas/ (custom CAS, gzip) | | stageMetadata/ (stage results by sig) | | taskMetadata/ (resource requests) | | resultMetadata/(build result metadata) | @@ -327,10 +328,12 @@ project.getProjectResources().setStage(stageName, stageCache.stage, ### On Disk (CacheManager) ``` -~/.ui5/buildCache/v0_2/ -+-- cas/ # Content-addressable storage (cacache, gzip) -| +-- content-v2/ -| +-- sha256/... # Resources stored by integrity hash +~/.ui5/buildCache/v0_3/ ++-- cas/ # Custom CAS (ContentAddressableStorage, gzip) +| +-- sha256/ # Resources stored by integrity hash +| +-- {xx}/ # First 2 hex chars of digest +| +-- {yy}/ # Next 2 hex chars +| +-- {rest} # Remaining hex chars (gzip-compressed content) +-- buildManifests/ # Build metadata per project (plain JSON) | +-- {projectId}/ | +-- {buildSignature}.json @@ -375,7 +378,7 @@ Stage metadata stored on disk includes: 2. **Request batching**: Multiple pending build requests processed in single batch (10ms debounce) 3. **Abort/retry**: File changes abort running builds; projects re-queued automatically 4. **Structural sharing**: Derived hash trees share unchanged subtrees, reducing memory -5. **Content-addressed storage**: Resources deduplicated via integrity hashes in cacache +5. **Content-addressed storage**: Resources deduplicated via integrity hashes in custom CAS (synchronous path resolution, gzip-compressed) 6. **Differential caching**: Tasks track resource requests; delta builds only re-process changed resources 7. **Tag propagation**: Resource tags flow through stages via cached tag operations, included in hash signatures 8. **Two-tier cache**: Fast in-memory StageCache + persistent filesystem cache via CacheManager From 393e485d3b42e95d05e769177ac4dde1028c17bd Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Apr 2026 13:50:08 +0200 Subject: [PATCH 217/223] perf(fs): Add CAS-aware fast path in FileSystem._write() Skip the PassThrough intermediary stream when writing CAS-backed resources to disk. Instead, pipe the resource stream directly to the write stream, eliminating one pipe hop and one stream object per resource while maintaining stream backpressure for I/O throttling at scale. --- packages/fs/lib/adapters/FileSystem.js | 39 +++++++++ .../fs/test/lib/adapters/FileSystem_write.js | 85 ++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/packages/fs/lib/adapters/FileSystem.js b/packages/fs/lib/adapters/FileSystem.js index 84c133adbc1..2319a9d0b99 100644 --- a/packages/fs/lib/adapters/FileSystem.js +++ b/packages/fs/lib/adapters/FileSystem.js @@ -291,6 +291,45 @@ class FileSystem extends AbstractAdapter { } else {/* Different paths + modifications require no special handling */} } + if (sourceMetadata && sourceMetadata.adapter === "CAS" && sourceMetadata.fsPath && + !sourceMetadata.contentModified) { + // CAS-backed resource: Stream directly to disk without PassThrough intermediary. + // This eliminates one pipe hop and one stream object per resource while maintaining + // stream backpressure for I/O throttling at scale (14k+ concurrent writes). + log.silly(`Writing CAS resource to ${fsPath}`); + await new Promise((resolve, reject) => { + const contentStream = resource.getStream(); + contentStream.on("error", (err) => { + reject(err); + }); + + if (!drain) { + // Capture decompressed content for future getBuffer() calls + const buffers = []; + contentStream.on("data", (data) => { + buffers.push(data); + }); + contentStream.on("end", () => { + resource.setBuffer(Buffer.concat(buffers)); + }); + } + + const writeOptions = {}; + if (readOnly) { + writeOptions.mode = READ_ONLY_MODE; + } + const write = fs.createWriteStream(fsPath, writeOptions); + write.on("error", (err) => { + reject(err); + }); + write.on("close", () => { + resolve(); + }); + contentStream.pipe(write); + }); + return; + } + log.silly(`Writing to ${fsPath}`); await new Promise((resolve, reject) => { diff --git a/packages/fs/test/lib/adapters/FileSystem_write.js b/packages/fs/test/lib/adapters/FileSystem_write.js index 33b3a72f021..0d7a2fb0053 100644 --- a/packages/fs/test/lib/adapters/FileSystem_write.js +++ b/packages/fs/test/lib/adapters/FileSystem_write.js @@ -1,7 +1,11 @@ import path from "node:path"; -import {readFile} from "node:fs/promises"; +import {readFile, writeFile as fsWriteFile} from "node:fs/promises"; import {access as fsAccess, constants as fsConstants, mkdir} from "node:fs/promises"; import {fileURLToPath} from "node:url"; +import {gzipSync} from "node:zlib"; +import {gunzip, createGunzip} from "node:zlib"; +import {promisify} from "node:util"; +import fs from "graceful-fs"; import test from "ava"; import {rimraf} from "rimraf"; import sinon from "sinon"; @@ -305,3 +309,82 @@ test("Migration of resource is executed", async (t) => { await t.notThrowsAsync(fileEqual(t, destFsPath, "./test/fixtures/application.a/webapp/index.html")); await t.notThrowsAsync(resource.getBuffer(), "Resource content can still be accessed"); }); + +async function createCasResource(t, content, virPath) { + const compressedContent = gzipSync(Buffer.from(content, "utf8")); + const casDir = path.join(t.context.tmpDirPath, ".cas"); + await mkdir(casDir, {recursive: true}); + const casFilePath = path.join(casDir, path.basename(virPath) + ".gz"); + await fsWriteFile(casFilePath, compressedContent); + + return createResource({ + path: virPath, + sourceMetadata: { + adapter: "CAS", + fsPath: casFilePath, + contentModified: false, + }, + createStream: () => { + return fs.createReadStream(casFilePath).pipe(createGunzip()); + }, + createBuffer: async () => { + const compressedBuffer = await promisify(fs.readFile)(casFilePath); + return await promisify(gunzip)(compressedBuffer); + }, + }); +} + +test("Write CAS resource", async (t) => { + const readerWriters = t.context.readerWriters; + const destFsPath = path.join(t.context.tmpDirPath, "index.html"); + const content = "CAS resource content"; + + const resource = await createCasResource(t, content, "/app/index.html"); + await readerWriters.dest.write(resource); + + await t.notThrowsAsync(fileContent(t, destFsPath, content)); + await t.notThrowsAsync(resource.getBuffer(), "Resource content can still be accessed"); +}); + +test("Write CAS resource in readOnly mode", async (t) => { + const readerWriters = t.context.readerWriters; + const destFsPath = path.join(t.context.tmpDirPath, "index.html"); + const content = "CAS resource content readOnly"; + + const resource = await createCasResource(t, content, "/app/index.html"); + await readerWriters.dest.write(resource, {readOnly: true}); + + await t.notThrowsAsync(fileContent(t, destFsPath, content)); + await t.notThrowsAsync(fsAccess(destFsPath, fsConstants.R_OK), "File can be read"); + await t.throwsAsync(fsAccess(destFsPath, fsConstants.W_OK), + {message: /EACCES: permission denied|EPERM: operation not permitted/}, + "File can not be written"); + await t.notThrowsAsync(resource.getBuffer(), "Resource content can still be accessed"); +}); + +test("Write CAS resource in drain mode", async (t) => { + const readerWriters = t.context.readerWriters; + const destFsPath = path.join(t.context.tmpDirPath, "index.html"); + const content = "CAS resource content drain"; + + const resource = await createCasResource(t, content, "/app/index.html"); + await readerWriters.dest.write(resource, {drain: true}); + + await t.notThrowsAsync(fileContent(t, destFsPath, content)); + await t.throwsAsync(resource.getBuffer(), + {message: /Timeout waiting for content of Resource \/app\/index.html to become available./}); +}); + +test("Write modified CAS resource", async (t) => { + const readerWriters = t.context.readerWriters; + const destFsPath = path.join(t.context.tmpDirPath, "index.html"); + const modifiedContent = "Modified CAS content"; + + const resource = await createCasResource(t, "Original CAS content", "/app/index.html"); + resource.setString(modifiedContent); + + await readerWriters.dest.write(resource); + + await t.notThrowsAsync(fileContent(t, destFsPath, modifiedContent)); + await t.notThrowsAsync(resource.getBuffer(), "Resource content can still be accessed"); +}); From 3d3b74d46339922ce26efcda2edb2571fa26eb01 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 24 Apr 2026 10:17:13 +0200 Subject: [PATCH 218/223] test(project): Add tests for delta cacheInfo path in recordTaskResult Cover the previously untested delta merge logic (lines 798-848) that executes when recordTaskResult receives a cacheInfo object from a delta cache hit. Five new tests verify resource merging, tag import, tag merge precedence, signature passthrough, and getCachedWriter fallback. --- .../test/lib/build/cache/ProjectBuildCache.js | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 47927c8c0d7..4d489a5c2f4 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -40,6 +40,7 @@ function createMockProject(name = "test.project", id = "test-project-id") { }), buildFinished: sinon.stub(), setFrozenSourceReader: sinon.stub(), + importTagOperations: sinon.stub(), }; return { @@ -351,6 +352,283 @@ test("recordTaskResult with empty requests", async (t) => { t.truthy(taskCache, "Task cache created even with no requests"); }); +// ===== DELTA (CACHEINFO) PATH IN RECORDTASKRESULT TESTS ===== + +test("recordTaskResult with cacheInfo: merges resources from previous stage, skipping already-written paths", + async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); + + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + // Resources written by the delta execution + const deltaWrittenRes = createMockResource("/a.js", "hash-a-new", 2000, 200, 2); + const writeStub = sinon.stub().resolves(); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([deltaWrittenRes]), + write: writeStub, + }) + }); + + // Resources from the previous stage cache (Reader-type: has byGlob directly) + const prevResA = createMockResource("/a.js", "hash-a-old", 1000, 100, 1); + const prevResB = createMockResource("/b.js", "hash-b", 1000, 100, 2); + const prevResC = createMockResource("/c.js", "hash-c", 1000, 100, 3); + + const cacheInfo = { + previousStageCache: { + signature: "prev-proj-prev-dep", + stage: { + byGlob: sinon.stub().resolves([prevResA, prevResB, prevResC]), + }, + writtenResourcePaths: ["/a.js", "/b.js", "/c.js"], + projectTagOperations: undefined, + buildTagOperations: undefined, + }, + newSignature: "new-proj-new-dep", + changedProjectResourcePaths: ["/a.js"], + changedDependencyResourcePaths: [], + }; + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, cacheInfo, false); + + t.is(writeStub.callCount, 2, "Write called for 2 non-overlapping resources"); + const writtenPaths = writeStub.getCalls().map((call) => call.args[0].getOriginalPath()); + t.true(writtenPaths.includes("/b.js"), "Previous resource /b.js merged into stage"); + t.true(writtenPaths.includes("/c.js"), "Previous resource /c.js merged into stage"); + t.false(writtenPaths.includes("/a.js"), "Already-written /a.js not merged"); + }); + +test("recordTaskResult with cacheInfo: calls importTagOperations with previous stage cache tags", + async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); + + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + const writeStub = sinon.stub().resolves(); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([]), + write: writeStub, + }) + }); + + const prevProjectTags = new Map([["/x.js", new Map([["ui5:IsDebugVariant", true]])]]); + const prevBuildTags = new Map([["/y.js", new Map([["ui5:OmitFromBuildResult", true]])]]); + + const cacheInfo = { + previousStageCache: { + signature: "prev-proj-prev-dep", + stage: { + byGlob: sinon.stub().resolves([]), + }, + writtenResourcePaths: [], + projectTagOperations: prevProjectTags, + buildTagOperations: prevBuildTags, + }, + newSignature: "new-proj-new-dep", + changedProjectResourcePaths: [], + changedDependencyResourcePaths: [], + }; + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, cacheInfo, false); + + const importStub = project.getProjectResources().importTagOperations; + t.true(importStub.calledOnce, "importTagOperations called once"); + t.is(importStub.firstCall.args[0], prevProjectTags, + "Called with previous stage projectTagOperations"); + t.is(importStub.firstCall.args[1], prevBuildTags, + "Called with previous stage buildTagOperations"); + }); + +test("recordTaskResult with cacheInfo: merges tag operations with current delta ops taking precedence", + async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); + + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + // Delta execution's own tag operations — /a.js IsDebugVariant overrides previous value + project.getProjectResources().getResourceTagOperations.returns({ + projectTagOperations: new Map([["/a.js", new Map([["ui5:IsDebugVariant", false]])]]), + buildTagOperations: new Map([["/c.js", new Map([["ui5:NewBuildTag", "val"]])]]), + }); + + // Set up stage mock with full resource metadata for writeCache to work + const writtenRes = createMockResource("/a.js", "hash-a", 2000, 200, 2); + const writeStub = sinon.stub().resolves(); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([writtenRes]), + write: writeStub, + }) + }); + + const cacheInfo = { + previousStageCache: { + signature: "prev-proj-prev-dep", + stage: { + byGlob: sinon.stub().resolves([]), + }, + writtenResourcePaths: [], + projectTagOperations: new Map([ + ["/a.js", new Map([["ui5:IsDebugVariant", true]])], + ["/b.js", new Map([["ui5:HasDebugVariant", true]])], + ]), + buildTagOperations: new Map([ + ["/a.js", new Map([["ui5:OldBuildTag", "old"]])], + ]), + }, + newSignature: "merged-sig", + changedProjectResourcePaths: [], + changedDependencyResourcePaths: [], + }; + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, cacheInfo, false); + + // Verify merged tags via writeCache -> cacheManager.writeStageCache + await cache.writeCache(); + + const stageCacheCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "task/myTask" + ); + t.is(stageCacheCalls.length, 1, "writeStageCache called once for task/myTask"); + + const metadata = stageCacheCalls[0].args[4]; + + // Project tag ops: /a.js should have delta's value (false), /b.js preserved from previous + t.is(metadata.projectTagOperations["/a.js"]["ui5:IsDebugVariant"], false, + "Delta's value for /a.js takes precedence over previous"); + t.is(metadata.projectTagOperations["/b.js"]["ui5:HasDebugVariant"], true, + "Previous value for /b.js preserved"); + + // Build tag ops: /a.js from previous, /c.js from delta + t.is(metadata.buildTagOperations["/a.js"]["ui5:OldBuildTag"], "old", + "Previous build tag for /a.js preserved"); + t.is(metadata.buildTagOperations["/c.js"]["ui5:NewBuildTag"], "val", + "Delta build tag for /c.js present"); + }); + +test("recordTaskResult with cacheInfo: uses cacheInfo.newSignature as stage signature", + async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); + + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + const writtenRes = createMockResource("/a.js", "hash-a", 2000, 200, 2); + const writeStub = sinon.stub().resolves(); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([writtenRes]), + write: writeStub, + }) + }); + + const cacheInfo = { + previousStageCache: { + signature: "prev-proj-prev-dep", + stage: { + byGlob: sinon.stub().resolves([]), + }, + writtenResourcePaths: [], + projectTagOperations: undefined, + buildTagOperations: undefined, + }, + newSignature: "custom-proj-sig-custom-dep-sig", + changedProjectResourcePaths: [], + changedDependencyResourcePaths: [], + }; + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, cacheInfo, false); + + await cache.writeCache(); + + const stageCacheCalls = cacheManager.writeStageCache.getCalls().filter( + (call) => call.args[2] === "task/myTask" + ); + t.is(stageCacheCalls.length, 1, "writeStageCache called once for task/myTask"); + t.is(stageCacheCalls[0].args[3], "custom-proj-sig-custom-dep-sig", + "Stage signature comes from cacheInfo.newSignature"); + }); + +test("recordTaskResult with cacheInfo: uses getCachedWriter fallback when getWriter returns null", + async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = await ProjectBuildCache.create(project, "sig", cacheManager); + await cache.initSourceIndex(); + + await cache.setTasks(["myTask"]); + await cache.prepareTaskExecutionAndValidateCache("myTask"); + + const writeStub = sinon.stub().resolves(); + project.getProjectResources().getStage.returns({ + getId: () => "task/myTask", + getWriter: sinon.stub().returns({ + byGlob: sinon.stub().resolves([]), + write: writeStub, + }) + }); + + // Previous stage is a Stage-type (no byGlob), getWriter returns null, getCachedWriter used + const prevResE = createMockResource("/e.js", "hash-e", 1000, 100, 5); + const getCachedWriterStub = sinon.stub().returns({ + byGlob: sinon.stub().resolves([prevResE]), + }); + + const cacheInfo = { + previousStageCache: { + signature: "prev-proj-prev-dep", + stage: { + getWriter: sinon.stub().returns(null), + getCachedWriter: getCachedWriterStub, + }, + writtenResourcePaths: ["/e.js"], + projectTagOperations: undefined, + buildTagOperations: undefined, + }, + newSignature: "new-proj-new-dep", + changedProjectResourcePaths: [], + changedDependencyResourcePaths: [], + }; + + const projectRequests = {paths: new Set(), patterns: new Set()}; + const dependencyRequests = {paths: new Set(), patterns: new Set()}; + await cache.recordTaskResult("myTask", projectRequests, dependencyRequests, cacheInfo, false); + + t.true(getCachedWriterStub.calledOnce, "getCachedWriter used as fallback"); + t.is(writeStub.callCount, 1, "Write called for 1 resource from cached writer"); + t.is(writeStub.firstCall.args[0].getOriginalPath(), "/e.js", + "Resource /e.js merged from getCachedWriter"); + }); + // ===== RESOURCE CHANGE TRACKING TESTS ===== test("projectSourcesChanged: marks cache as requiring validation", async (t) => { From ceca57c7b4699be4cafc2343666ee7e0d0466f0e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 27 Apr 2026 15:04:21 +0200 Subject: [PATCH 219/223] perf(project): Replace file-based metadata cache with SQLite storage Introduce MetadataStorage class backed by node:sqlite (DatabaseSync) to replace individual JSON file reads/writes in CacheManager. All four metadata types (index cache, stage metadata, task metadata, result metadata) are stored as JSON blobs in a single SQLite database with composite primary keys, WAL mode, and prepared statements. CacheManager remains the public interface, delegating metadata operations to MetadataStorage and binary content to CAS. Unused readBuildManifest/writeBuildManifest methods removed. CACHE_VERSION bumped to v0_4. --- .../project/lib/build/cache/CacheManager.js | 310 +++--------------- .../lib/build/cache/MetadataStorage.js | 280 ++++++++++++++++ .../test/lib/build/cache/CacheManager.js | 138 ++++++++ .../test/lib/build/cache/MetadataStorage.js | 172 ++++++++++ 4 files changed, 641 insertions(+), 259 deletions(-) create mode 100644 packages/project/lib/build/cache/MetadataStorage.js create mode 100644 packages/project/test/lib/build/cache/CacheManager.js create mode 100644 packages/project/test/lib/build/cache/MetadataStorage.js diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 50f9b7a3275..2097e640953 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,72 +1,58 @@ import path from "node:path"; -import fs from "graceful-fs"; -import {promisify} from "node:util"; -const mkdir = promisify(fs.mkdir); -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); import os from "node:os"; import Configuration from "../../config/Configuration.js"; -import {getPathFromPackageName} from "../../utils/sanitizeFileName.js"; import {getLogger} from "@ui5/logger"; import ContentAddressableStorage from "./ContentAddressableStorage.js"; +import MetadataStorage from "./MetadataStorage.js"; const log = getLogger("build:cache:CacheManager"); // Singleton instances mapped by cache directory path -const chacheManagerInstances = new Map(); +const cacheManagerInstances = new Map(); // Cache version for compatibility management -const CACHE_VERSION = "v0_3"; +const CACHE_VERSION = "v0_4"; /** - * Manages persistence for the build cache using file-based storage and a - * content-addressable storage (CAS) for resource content + * Manages persistence for the build cache using SQLite-backed metadata storage + * and a content-addressable storage (CAS) for resource content * - * CacheManager provides a hierarchical file-based cache structure: - * - cas/ - Content-addressable storage for resource content (gzip-compressed) - * - buildManifests/ - Build manifest files containing metadata about builds - * - stageMetadata/ - Stage-level metadata organized by project, build, and stage - * - index/ - Resource index files for efficient change detection + * CacheManager delegates metadata operations (index caches, stage metadata, + * task metadata, result metadata) to MetadataStorage (SQLite), and binary + * resource content to ContentAddressableStorage (file-based, gzip-compressed). * * The cache is organized by: - * 1. Project ID (sanitized package name) + * 1. Project ID (package name) * 2. Build signature (hash of build configuration) - * 3. Stage ID (e.g., "result" or "task/taskName") - * 4. Stage signature (hash of input resources) + * 3. Stage/task identifiers and signatures * * Key features: * - Content-addressable storage with synchronous path resolution * - Singleton pattern per cache directory * - Configurable cache location via UI5_DATA_DIR or configuration * - Efficient resource deduplication through content-addressable storage + * - SQLite-backed metadata for fast read/write operations * * @class */ export default class CacheManager { #cas; - #manifestDir; - #stageMetadataDir; - #taskMetadataDir; - #resultMetadataDir; - #indexDir; + #metadataStorage; /** * Creates a new CacheManager instance * - * Initializes the directory structure for the cache. This constructor is private - - * use CacheManager.create() instead to get a singleton instance. - * * @private * @param {string} cacheDir Base directory for the cache */ constructor(cacheDir) { - cacheDir = path.join(cacheDir, CACHE_VERSION); - this.#cas = new ContentAddressableStorage(path.join(cacheDir, "cas")); - this.#manifestDir = path.join(cacheDir, "buildManifests"); - this.#stageMetadataDir = path.join(cacheDir, "stageMetadata"); - this.#taskMetadataDir = path.join(cacheDir, "taskMetadata"); - this.#resultMetadataDir = path.join(cacheDir, "resultMetadata"); - this.#indexDir = path.join(cacheDir, "index"); + const versionedDir = path.join(cacheDir, CACHE_VERSION); + this.#cas = new ContentAddressableStorage(path.join(versionedDir, "cas")); + this.#metadataStorage = new MetadataStorage(versionedDir); + } + + #isValid() { + return this.#metadataStorage.isValid; } /** @@ -97,249 +83,87 @@ export default class CacheManager { const cacheDir = path.join(ui5DataDir, "buildCache"); log.verbose(`Using build cache directory: ${cacheDir}`); - if (!chacheManagerInstances.has(cacheDir)) { - chacheManagerInstances.set(cacheDir, new CacheManager(cacheDir)); - } - return chacheManagerInstances.get(cacheDir); - } - - /** - * Generates the file path for a build manifest - * - * @param {string} packageName Package/project identifier - * @param {string} buildSignature Build signature hash - * @returns {string} Absolute path to the build manifest file - */ - #getBuildManifestPath(packageName, buildSignature) { - const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#manifestDir, pkgDir, `${buildSignature}.json`); - } - - /** - * Reads a build manifest from cache - * - * @public - * @param {string} projectId Project identifier (typically package name) - * @param {string} buildSignature Build signature hash - * @returns {Promise} Parsed manifest object or null if not found - * @throws {Error} If file read fails for reasons other than file not existing - */ - async readBuildManifest(projectId, buildSignature) { - try { - const manifest = await readFile(this.#getBuildManifestPath(projectId, buildSignature), "utf8"); - return JSON.parse(manifest); - } catch (err) { - if (err.code === "ENOENT") { - // Cache miss - return null; - } - throw new Error(`Failed to read build manifest for ` + - `${projectId} / ${buildSignature}: ${err.message}`, { - cause: err, - }); + if (!cacheManagerInstances.has(cacheDir) || !cacheManagerInstances.get(cacheDir).#isValid()) { + cacheManagerInstances.set(cacheDir, new CacheManager(cacheDir)); } - } - - /** - * Writes a build manifest to cache - * - * Creates parent directories if they don't exist. Manifests are stored as - * formatted JSON (2-space indentation) for readability. - * - * @public - * @param {string} projectId Project identifier (typically package name) - * @param {string} buildSignature Build signature hash - * @param {object} manifest Build manifest object to serialize - * @returns {Promise} - */ - async writeBuildManifest(projectId, buildSignature, manifest) { - const manifestPath = this.#getBuildManifestPath(projectId, buildSignature); - await mkdir(path.dirname(manifestPath), {recursive: true}); - await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); - } - - /** - * Generates the file path for resource index metadata - * - * @param {string} packageName Package/project identifier - * @param {string} buildSignature Build signature hash - * @param {string} kind "source" or "result" - * @returns {string} Absolute path to the index metadata file - */ - #getIndexCachePath(packageName, buildSignature, kind) { - const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#indexDir, pkgDir, `${kind}-${buildSignature}.json`); + return cacheManagerInstances.get(cacheDir); } /** * Reads resource index cache from storage * - * The index cache contains the resource tree structure and task metadata, - * enabling efficient change detection and cache validation. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash * @param {string} kind "source" or "result" * @returns {Promise} Parsed index cache object or null if not found - * @throws {Error} If file read fails for reasons other than file not existing */ async readIndexCache(projectId, buildSignature, kind) { - try { - const metadata = await readFile(this.#getIndexCachePath(projectId, buildSignature, kind), "utf8"); - return JSON.parse(metadata); - } catch (err) { - if (err.code === "ENOENT") { - // Cache miss - return null; - } - throw new Error(`Failed to read resource index cache for ` + - `${projectId} / ${buildSignature}: ${err.message}`, { - cause: err, - }); - } + return this.#metadataStorage.readIndexCache(projectId, buildSignature, kind); } /** * Writes resource index cache to storage * - * Persists the resource index and associated task metadata for later retrieval. - * Creates parent directories if needed. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash * @param {string} kind "source" or "result" * @param {object} index Index object containing resource tree and task metadata * @returns {Promise} */ async writeIndexCache(projectId, buildSignature, kind, index) { - const indexPath = this.#getIndexCachePath(projectId, buildSignature, kind); - await mkdir(path.dirname(indexPath), {recursive: true}); - await writeFile(indexPath, JSON.stringify(index, null, 2), "utf8"); - } - - /** - * Generates the file path for stage metadata - * - * @param {string} packageName Package/project identifier - * @param {string} buildSignature Build signature hash - * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature Stage signature hash (based on input resources) - * @returns {string} Absolute path to the stage metadata file - */ - #getStageMetadataPath(packageName, buildSignature, stageId, stageSignature) { - const pkgDir = getPathFromPackageName(packageName); - stageId = stageId.replace("/", "_"); - return path.join(this.#stageMetadataDir, pkgDir, buildSignature, stageId, `${stageSignature}.json`); + this.#metadataStorage.writeIndexCache(projectId, buildSignature, kind, index); } /** * Reads stage metadata from cache * - * Stage metadata contains information about resources produced by a build stage, - * including resource paths and their metadata. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash - * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature Stage signature hash (based on input resources) + * @param {string} stageId Stage identifier + * @param {string} stageSignature Stage signature hash * @returns {Promise} Parsed stage metadata or null if not found - * @throws {Error} If file read fails for reasons other than file not existing */ async readStageCache(projectId, buildSignature, stageId, stageSignature) { - try { - const metadata = await readFile( - this.#getStageMetadataPath(projectId, buildSignature, stageId, stageSignature - ), "utf8"); - return JSON.parse(metadata); - } catch (err) { - if (err.code === "ENOENT") { - // Cache miss - return null; - } - throw new Error(`Failed to read stage metadata from cache for ` + - `${projectId} / ${buildSignature} / ${stageId} / ${stageSignature}: ${err.message}`, { - cause: err, - }); - } + return this.#metadataStorage.readStageCache(projectId, buildSignature, stageId, stageSignature); } /** * Writes stage metadata to cache * - * Persists metadata about resources produced by a build stage. - * Creates parent directories if needed. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash - * @param {string} stageId Stage identifier (e.g., "result" or "task/taskName") - * @param {string} stageSignature Stage signature hash (based on input resources) + * @param {string} stageId Stage identifier + * @param {string} stageSignature Stage signature hash * @param {object} metadata Stage metadata object to serialize * @returns {Promise} */ async writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata) { - const metadataPath = this.#getStageMetadataPath( - projectId, buildSignature, stageId, stageSignature); - await mkdir(path.dirname(metadataPath), {recursive: true}); - await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); - } - - /** - * Generates the file path for task metadata - * - * @param {string} packageName Package/project identifier - * @param {string} buildSignature Build signature hash - * @param {string} taskName Task name - * @param {string} type "project" or "dependency" - * @returns {string} Absolute path to the task metadata file - */ - #getTaskMetadataPath(packageName, buildSignature, taskName, type) { - const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#taskMetadataDir, pkgDir, buildSignature, taskName, `${type}.json`); + this.#metadataStorage.writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata); } /** * Reads task metadata from cache * - * Task metadata contains resource request graphs and indices for tracking - * which resources a task accessed during execution. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash * @param {string} taskName Task name * @param {string} type "project" or "dependency" * @returns {Promise} Parsed task metadata or null if not found - * @throws {Error} If file read fails for reasons other than file not existing */ async readTaskMetadata(projectId, buildSignature, taskName, type) { - try { - const metadata = await readFile( - this.#getTaskMetadataPath(projectId, buildSignature, taskName, type), "utf8"); - return JSON.parse(metadata); - } catch (err) { - if (err.code === "ENOENT") { - // Cache miss - return null; - } - throw new Error(`Failed to read task metadata from cache for ` + - `${projectId} / ${buildSignature} / ${taskName} / ${type}: ${err.message}`, { - cause: err, - }); - } + return this.#metadataStorage.readTaskMetadata(projectId, buildSignature, taskName, type); } /** * Writes task metadata to cache * - * Persists task-specific metadata including resource request graphs and indices. - * Creates parent directories if needed. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash * @param {string} taskName Task name * @param {string} type "project" or "dependency" @@ -347,73 +171,34 @@ export default class CacheManager { * @returns {Promise} */ async writeTaskMetadata(projectId, buildSignature, taskName, type, metadata) { - const metadataPath = this.#getTaskMetadataPath(projectId, buildSignature, taskName, type); - await mkdir(path.dirname(metadataPath), {recursive: true}); - await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); - } - - /** - * Generates the file path for result metadata - * - * @param {string} packageName Package/project identifier - * @param {string} buildSignature Build signature hash - * @param {string} stageSignature Stage signature hash (based on input resources) - * @returns {string} Absolute path to the result metadata file - */ - #getResultMetadataPath(packageName, buildSignature, stageSignature) { - const pkgDir = getPathFromPackageName(packageName); - return path.join(this.#resultMetadataDir, pkgDir, buildSignature, `${stageSignature}.json`); + this.#metadataStorage.writeTaskMetadata(projectId, buildSignature, taskName, type, metadata); } /** * Reads result metadata from cache * - * Result metadata contains information about the final build output, including - * references to all stage signatures that comprise the result. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash - * @param {string} stageSignature Stage signature hash (based on input resources) + * @param {string} stageSignature Stage signature hash * @returns {Promise} Parsed result metadata or null if not found - * @throws {Error} If file read fails for reasons other than file not existing */ async readResultMetadata(projectId, buildSignature, stageSignature) { - try { - const metadata = await readFile( - this.#getResultMetadataPath(projectId, buildSignature, stageSignature - ), "utf8"); - return JSON.parse(metadata); - } catch (err) { - if (err.code === "ENOENT") { - // Cache miss - return null; - } - throw new Error(`Failed to read stage metadata from cache for ` + - `${projectId} / ${buildSignature} / ${stageSignature}: ${err.message}`, { - cause: err, - }); - } + return this.#metadataStorage.readResultMetadata(projectId, buildSignature, stageSignature); } /** * Writes result metadata to cache * - * Persists metadata about the final build result, including stage signature mappings. - * Creates parent directories if needed. - * * @public - * @param {string} projectId Project identifier (typically package name) + * @param {string} projectId Project identifier * @param {string} buildSignature Build signature hash - * @param {string} stageSignature Stage signature hash (based on input resources) + * @param {string} stageSignature Stage signature hash * @param {object} metadata Result metadata object to serialize * @returns {Promise} */ async writeResultMetadata(projectId, buildSignature, stageSignature, metadata) { - const metadataPath = this.#getResultMetadataPath( - projectId, buildSignature, stageSignature); - await mkdir(path.dirname(metadataPath), {recursive: true}); - await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + this.#metadataStorage.writeResultMetadata(projectId, buildSignature, stageSignature, metadata); } /** @@ -464,4 +249,11 @@ export default class CacheManager { const buffer = await resource.getBuffer(); await this.#cas.put(integrity, buffer); } + + /** + * Closes the metadata storage + */ + close() { + this.#metadataStorage.close(); + } } diff --git a/packages/project/lib/build/cache/MetadataStorage.js b/packages/project/lib/build/cache/MetadataStorage.js new file mode 100644 index 00000000000..40c0c0d8306 --- /dev/null +++ b/packages/project/lib/build/cache/MetadataStorage.js @@ -0,0 +1,280 @@ +import {DatabaseSync} from "node:sqlite"; +import {mkdirSync, existsSync} from "node:fs"; +import path from "node:path"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("build:cache:MetadataStorage"); + +/** + * SQLite-backed metadata storage for the build cache + * + * Stores build metadata (index caches, stage metadata, task metadata, result metadata) + * as JSON blobs in a single SQLite database keyed by composite primary keys. + * + * @class + */ +export default class MetadataStorage { + #db; + #stmts; + #dbPath; + + /** + * @param {string} dbDir Directory in which to create the metadata.db file + */ + constructor(dbDir) { + mkdirSync(dbDir, {recursive: true}); + this.#dbPath = path.join(dbDir, "metadata.db"); + log.verbose(`Opening metadata database: ${this.#dbPath}`); + + this.#db = new DatabaseSync(this.#dbPath); + this.#db.exec("PRAGMA journal_mode=WAL"); + this.#db.exec("PRAGMA synchronous=NORMAL"); + this.#db.exec("PRAGMA busy_timeout=5000"); + + this.#createTables(); + this.#prepareStatements(); + } + + #createTables() { + this.#db.exec(` + CREATE TABLE IF NOT EXISTS index_cache ( + project_id TEXT NOT NULL, + build_signature TEXT NOT NULL, + kind TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (project_id, build_signature, kind) + ) WITHOUT ROWID; + + CREATE TABLE IF NOT EXISTS stage_metadata ( + project_id TEXT NOT NULL, + build_signature TEXT NOT NULL, + stage_id TEXT NOT NULL, + stage_signature TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (project_id, build_signature, stage_id, stage_signature) + ) WITHOUT ROWID; + + CREATE TABLE IF NOT EXISTS task_metadata ( + project_id TEXT NOT NULL, + build_signature TEXT NOT NULL, + task_name TEXT NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (project_id, build_signature, task_name, type) + ) WITHOUT ROWID; + + CREATE TABLE IF NOT EXISTS result_metadata ( + project_id TEXT NOT NULL, + build_signature TEXT NOT NULL, + stage_signature TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (project_id, build_signature, stage_signature) + ) WITHOUT ROWID; + `); + } + + #prepareStatements() { + this.#stmts = { + readIndexCache: this.#db.prepare( + "SELECT data FROM index_cache WHERE project_id = ? AND build_signature = ? AND kind = ?" + ), + writeIndexCache: this.#db.prepare( + `INSERT OR REPLACE INTO index_cache (project_id, build_signature, kind, data) + VALUES (?, ?, ?, ?)` + ), + + readStageMetadata: this.#db.prepare( + `SELECT data FROM stage_metadata + WHERE project_id = ? AND build_signature = ? AND stage_id = ? AND stage_signature = ?` + ), + writeStageMetadata: this.#db.prepare( + `INSERT OR REPLACE INTO stage_metadata + (project_id, build_signature, stage_id, stage_signature, data) VALUES (?, ?, ?, ?, ?)` + ), + + readTaskMetadata: this.#db.prepare( + `SELECT data FROM task_metadata + WHERE project_id = ? AND build_signature = ? AND task_name = ? AND type = ?` + ), + writeTaskMetadata: this.#db.prepare( + `INSERT OR REPLACE INTO task_metadata + (project_id, build_signature, task_name, type, data) VALUES (?, ?, ?, ?, ?)` + ), + + readResultMetadata: this.#db.prepare( + `SELECT data FROM result_metadata + WHERE project_id = ? AND build_signature = ? AND stage_signature = ?` + ), + writeResultMetadata: this.#db.prepare( + `INSERT OR REPLACE INTO result_metadata + (project_id, build_signature, stage_signature, data) VALUES (?, ?, ?, ?)` + ), + }; + } + + /** + * Whether the database connection is open and the database file still exists on disk. + * This detects cases where the cache directory was deleted externally + * (e.g., by test cleanup) while the connection was still open. + * + * @returns {boolean} + */ + get isValid() { + return this.#db.isOpen && existsSync(this.#dbPath); + } + + /** + * Closes the database connection + */ + close() { + this.#db.close(); + } + + /** + * Reads resource index cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} kind "source" or "result" + * @returns {object|null} Parsed index cache object or null if not found + */ + readIndexCache(projectId, buildSignature, kind) { + try { + const row = this.#stmts.readIndexCache.get(projectId, buildSignature, kind); + return row ? JSON.parse(row.data) : null; + } catch (err) { + throw new Error( + `Failed to read resource index cache for ` + + `${projectId} / ${buildSignature}: ${err.message}`, + {cause: err} + ); + } + } + + /** + * Writes resource index cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} kind "source" or "result" + * @param {object} index Index object to serialize + */ + writeIndexCache(projectId, buildSignature, kind, index) { + this.#stmts.writeIndexCache.run(projectId, buildSignature, kind, JSON.stringify(index)); + } + + /** + * Reads stage metadata from cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier + * @param {string} stageSignature Stage signature hash + * @returns {object|null} Parsed stage metadata or null if not found + */ + readStageCache(projectId, buildSignature, stageId, stageSignature) { + try { + const row = this.#stmts.readStageMetadata.get( + projectId, buildSignature, stageId, stageSignature + ); + return row ? JSON.parse(row.data) : null; + } catch (err) { + throw new Error( + `Failed to read stage metadata from cache for ` + + `${projectId} / ${buildSignature} / ${stageId} / ${stageSignature}: ${err.message}`, + {cause: err} + ); + } + } + + /** + * Writes stage metadata to cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} stageId Stage identifier + * @param {string} stageSignature Stage signature hash + * @param {object} metadata Stage metadata object to serialize + */ + writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata) { + this.#stmts.writeStageMetadata.run( + projectId, buildSignature, stageId, stageSignature, JSON.stringify(metadata) + ); + } + + /** + * Reads task metadata from cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} taskName Task name + * @param {string} type "project" or "dependency" + * @returns {object|null} Parsed task metadata or null if not found + */ + readTaskMetadata(projectId, buildSignature, taskName, type) { + try { + const row = this.#stmts.readTaskMetadata.get( + projectId, buildSignature, taskName, type + ); + return row ? JSON.parse(row.data) : null; + } catch (err) { + throw new Error( + `Failed to read task metadata from cache for ` + + `${projectId} / ${buildSignature} / ${taskName} / ${type}: ${err.message}`, + {cause: err} + ); + } + } + + /** + * Writes task metadata to cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} taskName Task name + * @param {string} type "project" or "dependency" + * @param {object} metadata Task metadata object to serialize + */ + writeTaskMetadata(projectId, buildSignature, taskName, type, metadata) { + this.#stmts.writeTaskMetadata.run( + projectId, buildSignature, taskName, type, JSON.stringify(metadata) + ); + } + + /** + * Reads result metadata from cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} stageSignature Stage signature hash + * @returns {object|null} Parsed result metadata or null if not found + */ + readResultMetadata(projectId, buildSignature, stageSignature) { + try { + const row = this.#stmts.readResultMetadata.get( + projectId, buildSignature, stageSignature + ); + return row ? JSON.parse(row.data) : null; + } catch (err) { + throw new Error( + `Failed to read result metadata from cache for ` + + `${projectId} / ${buildSignature} / ${stageSignature}: ${err.message}`, + {cause: err} + ); + } + } + + /** + * Writes result metadata to cache + * + * @param {string} projectId Project identifier + * @param {string} buildSignature Build signature hash + * @param {string} stageSignature Stage signature hash + * @param {object} metadata Result metadata object to serialize + */ + writeResultMetadata(projectId, buildSignature, stageSignature, metadata) { + this.#stmts.writeResultMetadata.run( + projectId, buildSignature, stageSignature, JSON.stringify(metadata) + ); + } +} diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js new file mode 100644 index 00000000000..05ada670848 --- /dev/null +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -0,0 +1,138 @@ +import test from "ava"; +import path from "node:path"; +import sinon from "sinon"; +import esmock from "esmock"; +import {rimraf} from "rimraf"; + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "CacheManager"); + +test.after.always(async () => { + await rimraf(TEST_DIR); +}); + +test.afterEach.always(() => { + sinon.restore(); + delete process.env.UI5_DATA_DIR; +}); + +function getUniqueTestDir() { + return path.join(TEST_DIR, `cm-${Date.now()}-${Math.random().toString(36).slice(2)}`); +} + +// Metadata delegation (round-trip through CacheManager) + +test.serial("Index cache: round-trip via CacheManager", async (t) => { + const testDir = getUniqueTestDir(); + process.env.UI5_DATA_DIR = testDir; + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + const data = {indexTimestamp: 1234, root: {name: "", hash: "abc"}}; + await cm.writeIndexCache("project-x", "build-sig", "source", data); + const result = await cm.readIndexCache("project-x", "build-sig", "source"); + t.deepEqual(result, data); + cm.close(); +}); + +test.serial("Stage cache: round-trip via CacheManager", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + const data = {resourceMetadata: {"/a.js": {integrity: "hash-a"}}}; + await cm.writeStageCache("project-x", "build-sig", "task/minify", "stage-sig", data); + const result = await cm.readStageCache("project-x", "build-sig", "task/minify", "stage-sig"); + t.deepEqual(result, data); + cm.close(); +}); + +test.serial("Task metadata: round-trip via CacheManager", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + const data = {requestSetGraph: {nodes: []}}; + await cm.writeTaskMetadata("project-x", "build-sig", "minify", "project", data); + const result = await cm.readTaskMetadata("project-x", "build-sig", "minify", "project"); + t.deepEqual(result, data); + cm.close(); +}); + +test.serial("Result metadata: round-trip via CacheManager", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + const data = {stageSignatures: {"task/minify": "sig-abc"}}; + await cm.writeResultMetadata("project-x", "build-sig", "result-sig", data); + const result = await cm.readResultMetadata("project-x", "build-sig", "result-sig"); + t.deepEqual(result, data); + cm.close(); +}); + +test.serial("Cache miss returns null for all metadata types", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + t.is(await cm.readIndexCache("no-project", "no-sig", "source"), null); + t.is(await cm.readStageCache("no-project", "no-sig", "no-stage", "no-sig"), null); + t.is(await cm.readTaskMetadata("no-project", "no-sig", "no-task", "project"), null); + t.is(await cm.readResultMetadata("no-project", "no-sig", "no-sig"), null); + cm.close(); +}); + +// CAS delegation + +test.serial("contentPath delegates to CAS", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + const result = cm.contentPath(integrity); + t.is(typeof result, "string"); + t.true(result.includes("cas")); + t.true(result.includes("sha256")); + cm.close(); +}); + +test.serial("getResourcePathForStage returns null for missing content", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + const result = await cm.getResourcePathForStage("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="); + t.is(result, null); + cm.close(); +}); + +test.serial("getResourcePathForStage throws without integrity", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + + await t.throwsAsync(cm.getResourcePathForStage(null), { + message: "Integrity hash must be provided to read from cache" + }); + cm.close(); +}); + +// Singleton via create() + +test.serial("create() returns singleton per cache directory", async (t) => { + const testDir = getUniqueTestDir(); + process.env.UI5_DATA_DIR = testDir; + + const CacheManager = await esmock("../../../../lib/build/cache/CacheManager.js", { + "../../../../lib/config/Configuration.js": { + default: { + fromFile: sinon.stub().resolves({getUi5DataDir: () => null}) + } + } + }); + + const cm1 = await CacheManager.create(testDir); + const cm2 = await CacheManager.create(testDir); + t.is(cm1, cm2, "Same cache directory returns same instance"); +}); diff --git a/packages/project/test/lib/build/cache/MetadataStorage.js b/packages/project/test/lib/build/cache/MetadataStorage.js new file mode 100644 index 00000000000..3ccba694598 --- /dev/null +++ b/packages/project/test/lib/build/cache/MetadataStorage.js @@ -0,0 +1,172 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs"; +import {rimraf} from "rimraf"; +import MetadataStorage from "../../../../lib/build/cache/MetadataStorage.js"; + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "MetadataStorage"); + +test.after.always(async () => { + await rimraf(TEST_DIR); +}); + +test.beforeEach((t) => { + t.context.dbDir = path.join(TEST_DIR, `db-${Date.now()}-${Math.random().toString(36).slice(2)}`); + t.context.storage = new MetadataStorage(t.context.dbDir); +}); + +test.afterEach.always((t) => { + try { + t.context.storage.close(); + } catch { + // Already closed (e.g., in error-handling tests) + } +}); + +// Database file creation + +test("Creates metadata.db in the specified directory", (t) => { + const dbPath = path.join(t.context.dbDir, "metadata.db"); + t.true(fs.existsSync(dbPath)); +}); + +// Index cache + +test("readIndexCache: Returns null on cache miss", (t) => { + const result = t.context.storage.readIndexCache("project-a", "sig-1", "source"); + t.is(result, null); +}); + +test("Index cache: Round-trip write and read", (t) => { + const data = {indexTimestamp: 1000, root: {name: "", type: "directory", hash: "abc"}}; + t.context.storage.writeIndexCache("project-a", "sig-1", "source", data); + const result = t.context.storage.readIndexCache("project-a", "sig-1", "source"); + t.deepEqual(result, data); +}); + +test("Index cache: Different kind values are independent", (t) => { + const sourceData = {kind: "source", value: 1}; + const resultData = {kind: "result", value: 2}; + t.context.storage.writeIndexCache("project-a", "sig-1", "source", sourceData); + t.context.storage.writeIndexCache("project-a", "sig-1", "result", resultData); + + t.deepEqual(t.context.storage.readIndexCache("project-a", "sig-1", "source"), sourceData); + t.deepEqual(t.context.storage.readIndexCache("project-a", "sig-1", "result"), resultData); +}); + +test("Index cache: Overwrite replaces data", (t) => { + const original = {version: 1}; + const updated = {version: 2}; + t.context.storage.writeIndexCache("project-a", "sig-1", "source", original); + t.context.storage.writeIndexCache("project-a", "sig-1", "source", updated); + t.deepEqual(t.context.storage.readIndexCache("project-a", "sig-1", "source"), updated); +}); + +// Stage metadata + +test("readStageCache: Returns null on cache miss", (t) => { + const result = t.context.storage.readStageCache("project-a", "sig-1", "task/minify", "stage-sig-1"); + t.is(result, null); +}); + +test("Stage metadata: Round-trip write and read", (t) => { + const data = {resourceMetadata: {"/a.js": {integrity: "hash-a"}}}; + t.context.storage.writeStageCache("project-a", "sig-1", "task/minify", "stage-sig-1", data); + const result = t.context.storage.readStageCache("project-a", "sig-1", "task/minify", "stage-sig-1"); + t.deepEqual(result, data); +}); + +test("Stage metadata: Different stage signatures are independent", (t) => { + const data1 = {value: "first"}; + const data2 = {value: "second"}; + t.context.storage.writeStageCache("project-a", "sig-1", "task/minify", "stage-sig-1", data1); + t.context.storage.writeStageCache("project-a", "sig-1", "task/minify", "stage-sig-2", data2); + + t.deepEqual( + t.context.storage.readStageCache("project-a", "sig-1", "task/minify", "stage-sig-1"), data1 + ); + t.deepEqual( + t.context.storage.readStageCache("project-a", "sig-1", "task/minify", "stage-sig-2"), data2 + ); +}); + +test("Stage metadata: Stage IDs with slashes are stored correctly", (t) => { + const data = {value: "slash-test"}; + t.context.storage.writeStageCache("project-a", "sig-1", "task/myTask", "stage-sig-1", data); + t.deepEqual( + t.context.storage.readStageCache("project-a", "sig-1", "task/myTask", "stage-sig-1"), data + ); +}); + +// Task metadata + +test("readTaskMetadata: Returns null on cache miss", (t) => { + const result = t.context.storage.readTaskMetadata("project-a", "sig-1", "minify", "project"); + t.is(result, null); +}); + +test("Task metadata: Round-trip write and read", (t) => { + const data = {requestSetGraph: {nodes: [], nextId: 1}}; + t.context.storage.writeTaskMetadata("project-a", "sig-1", "minify", "project", data); + const result = t.context.storage.readTaskMetadata("project-a", "sig-1", "minify", "project"); + t.deepEqual(result, data); +}); + +test("Task metadata: Different types are independent", (t) => { + const projectData = {scope: "project"}; + const depData = {scope: "dependency"}; + t.context.storage.writeTaskMetadata("project-a", "sig-1", "minify", "project", projectData); + t.context.storage.writeTaskMetadata("project-a", "sig-1", "minify", "dependencies", depData); + + t.deepEqual( + t.context.storage.readTaskMetadata("project-a", "sig-1", "minify", "project"), projectData + ); + t.deepEqual( + t.context.storage.readTaskMetadata("project-a", "sig-1", "minify", "dependencies"), depData + ); +}); + +// Result metadata + +test("readResultMetadata: Returns null on cache miss", (t) => { + const result = t.context.storage.readResultMetadata("project-a", "sig-1", "result-sig-1"); + t.is(result, null); +}); + +test("Result metadata: Round-trip write and read", (t) => { + const data = {stageSignatures: {"task/minify": "sig-abc"}}; + t.context.storage.writeResultMetadata("project-a", "sig-1", "result-sig-1", data); + const result = t.context.storage.readResultMetadata("project-a", "sig-1", "result-sig-1"); + t.deepEqual(result, data); +}); + +test("Result metadata: Overwrite replaces data", (t) => { + const original = {version: 1}; + const updated = {version: 2}; + t.context.storage.writeResultMetadata("project-a", "sig-1", "result-sig-1", original); + t.context.storage.writeResultMetadata("project-a", "sig-1", "result-sig-1", updated); + t.deepEqual(t.context.storage.readResultMetadata("project-a", "sig-1", "result-sig-1"), updated); +}); + +// Cross-project isolation + +test("Different projects are fully isolated", (t) => { + const dataA = {project: "a"}; + const dataB = {project: "b"}; + t.context.storage.writeIndexCache("project-a", "sig-1", "source", dataA); + t.context.storage.writeIndexCache("project-b", "sig-1", "source", dataB); + + t.deepEqual(t.context.storage.readIndexCache("project-a", "sig-1", "source"), dataA); + t.deepEqual(t.context.storage.readIndexCache("project-b", "sig-1", "source"), dataB); +}); + +// Error handling + +test("Read throws wrapped error after close", (t) => { + t.context.storage.close(); + const err = t.throws(() => { + t.context.storage.readIndexCache("project-a", "sig-1", "source"); + }); + t.true(err.message.includes("Failed to read resource index cache")); + t.truthy(err.cause); +}); From d3a65e46cf7b9302b80033df670aa2f9ee930059 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 27 Apr 2026 16:11:58 +0200 Subject: [PATCH 220/223] perf(project): Replace file-based CAS with SQLite BLOB storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store gzip-compressed resource content as BLOBs in a SQLite database (content.db) instead of individual files in a directory tree. This eliminates per-file overhead (access, mkdir, writeFile+rename) and enables batch writes within a single transaction. Benchmarks on sap.m (11,893 untransformed sources): - Cold cache writeStageResources: 4605ms → 2915ms (-37%) - Cold cache total build: 25s → 22s (-12%) - Warm cache: unchanged (~35ms) Bumps cache version to v0_5. --- packages/fs/lib/adapters/FileSystem.js | 15 ++ .../project/lib/build/cache/CacheManager.js | 86 +++++++-- .../cache/ContentAddressableStorageSQLite.js | 163 ++++++++++++++++++ .../lib/build/cache/ProjectBuildCache.js | 43 +++-- 4 files changed, 272 insertions(+), 35 deletions(-) create mode 100644 packages/project/lib/build/cache/ContentAddressableStorageSQLite.js diff --git a/packages/fs/lib/adapters/FileSystem.js b/packages/fs/lib/adapters/FileSystem.js index 2319a9d0b99..33a6b798579 100644 --- a/packages/fs/lib/adapters/FileSystem.js +++ b/packages/fs/lib/adapters/FileSystem.js @@ -330,6 +330,21 @@ class FileSystem extends AbstractAdapter { return; } + if (sourceMetadata && sourceMetadata.adapter === "CAS_SQLITE" && + !sourceMetadata.contentModified) { + log.silly(`Writing CAS_SQLITE resource to ${fsPath}`); + const buffer = await resource.getBuffer(); + const writeOptions = {}; + if (readOnly) { + writeOptions.mode = READ_ONLY_MODE; + } + await writeFile(fsPath, buffer, writeOptions); + if (!drain) { + resource.setBuffer(buffer); + } + return; + } + log.silly(`Writing to ${fsPath}`); await new Promise((resolve, reject) => { diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 2097e640953..41576298f1c 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -2,7 +2,7 @@ import path from "node:path"; import os from "node:os"; import Configuration from "../../config/Configuration.js"; import {getLogger} from "@ui5/logger"; -import ContentAddressableStorage from "./ContentAddressableStorage.js"; +import ContentAddressableStorageSQLite from "./ContentAddressableStorageSQLite.js"; import MetadataStorage from "./MetadataStorage.js"; const log = getLogger("build:cache:CacheManager"); @@ -11,7 +11,7 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -const CACHE_VERSION = "v0_4"; +const CACHE_VERSION = "v0_5"; /** * Manages persistence for the build cache using SQLite-backed metadata storage @@ -47,12 +47,12 @@ export default class CacheManager { */ constructor(cacheDir) { const versionedDir = path.join(cacheDir, CACHE_VERSION); - this.#cas = new ContentAddressableStorage(path.join(versionedDir, "cas")); + this.#cas = new ContentAddressableStorageSQLite(path.join(versionedDir, "content.db")); this.#metadataStorage = new MetadataStorage(versionedDir); } #isValid() { - return this.#metadataStorage.isValid; + return this.#metadataStorage.isValid && this.#cas.isValid; } /** @@ -202,36 +202,51 @@ export default class CacheManager { } /** - * Computes the filesystem path for a CAS resource given its integrity hash - * - * This is synchronous — no I/O is performed. + * Checks whether content with the given integrity exists in storage * * @public * @param {string} integrity SRI integrity string (e.g., "sha256-...") - * @returns {string} Absolute filesystem path to the cached content file + * @returns {boolean} True if content exists + */ + hasContent(integrity) { + return this.#cas.has(integrity); + } + + /** + * Reads and decompresses content from the CAS + * + * @public + * @param {string} integrity SRI integrity string + * @returns {Buffer} Decompressed content buffer */ - contentPath(integrity) { - return this.#cas.contentPath(integrity); + readContent(integrity) { + return this.#cas.readContent(integrity); } /** - * Retrieves the file system path for a cached resource + * Reads the raw compressed BLOB from the CAS * - * Computes the content path from the integrity hash and verifies the file exists. + * @public + * @param {string} integrity SRI integrity string + * @returns {Buffer} Compressed content buffer + */ + readContentRaw(integrity) { + return this.#cas.readContentRaw(integrity); + } + + /** + * Checks whether content exists for the given integrity hash * * @public * @param {string} integrity Expected integrity hash (e.g., "sha256-...") - * @returns {Promise} Absolute path to the cached resource file, or null if not found + * @returns {boolean} True if content exists in storage * @throws {Error} If integrity is not provided */ - async getResourcePathForStage(integrity) { + hasResourceForStage(integrity) { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } - if (await this.#cas.has(integrity)) { - return this.#cas.contentPath(integrity); - } - return null; + return this.#cas.has(integrity); } /** @@ -247,13 +262,46 @@ export default class CacheManager { async writeStageResource(resource) { const integrity = await resource.getIntegrity(); const buffer = await resource.getBuffer(); - await this.#cas.put(integrity, buffer); + this.#cas.put(integrity, buffer); + } + + /** + * Writes content directly to the CAS with pre-fetched integrity and buffer + * + * @public + * @param {string} integrity SRI integrity string + * @param {Buffer} buffer Uncompressed resource content + */ + putContent(integrity, buffer) { + this.#cas.put(integrity, buffer); + } + + /** + * Begins a batch transaction for multiple content writes + */ + beginContentBatch() { + this.#cas.beginBatch(); + } + + /** + * Commits the current content batch transaction + */ + endContentBatch() { + this.#cas.endBatch(); + } + + /** + * Rolls back the current content batch transaction + */ + rollbackContentBatch() { + this.#cas.rollbackBatch(); } /** * Closes the metadata storage */ close() { + this.#cas.close(); this.#metadataStorage.close(); } } diff --git a/packages/project/lib/build/cache/ContentAddressableStorageSQLite.js b/packages/project/lib/build/cache/ContentAddressableStorageSQLite.js new file mode 100644 index 00000000000..63daafcb5cc --- /dev/null +++ b/packages/project/lib/build/cache/ContentAddressableStorageSQLite.js @@ -0,0 +1,163 @@ +import {DatabaseSync} from "node:sqlite"; +import {mkdirSync, existsSync} from "node:fs"; +import path from "node:path"; +import {gzipSync, gunzipSync} from "node:zlib"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("build:cache:ContentAddressableStorageSQLite"); + +/** + * SQLite-backed content-addressable storage for build cache resources + * + * Stores gzip-compressed content as BLOBs keyed by the original resource + * integrity hash. All reads and writes use synchronous DatabaseSync operations. + * + * @class + */ +export default class ContentAddressableStorageSQLite { + #db; + #stmts; + #dbPath; + #inBatch = false; + + /** + * @param {string} dbPath Path to the SQLite database file + */ + constructor(dbPath) { + mkdirSync(path.dirname(dbPath), {recursive: true}); + this.#dbPath = dbPath; + log.verbose(`Opening content database: ${this.#dbPath}`); + + this.#db = new DatabaseSync(this.#dbPath); + this.#db.exec("PRAGMA journal_mode=WAL"); + this.#db.exec("PRAGMA synchronous=NORMAL"); + this.#db.exec("PRAGMA busy_timeout=5000"); + this.#db.exec("PRAGMA page_size=8192"); + + this.#createTable(); + this.#prepareStatements(); + } + + #createTable() { + this.#db.exec(` + CREATE TABLE IF NOT EXISTS content ( + integrity TEXT PRIMARY KEY, + data BLOB NOT NULL + ) WITHOUT ROWID; + `); + } + + #prepareStatements() { + this.#stmts = { + has: this.#db.prepare( + "SELECT 1 FROM content WHERE integrity = ?" + ), + read: this.#db.prepare( + "SELECT data FROM content WHERE integrity = ?" + ), + write: this.#db.prepare( + "INSERT OR IGNORE INTO content (integrity, data) VALUES (?, ?)" + ), + }; + } + + /** + * Whether the database connection is open and the database file still exists on disk. + * + * @returns {boolean} + */ + get isValid() { + return this.#db.isOpen && existsSync(this.#dbPath); + } + + /** + * Checks whether content with the given integrity exists in storage + * + * @param {string} integrity SRI integrity string + * @returns {boolean} True if content exists + */ + has(integrity) { + return this.#stmts.has.get(integrity) !== undefined; + } + + /** + * Stores resource content in the CAS + * + * Compresses the buffer with gzip and stores it as a BLOB. + * Deduplicates: skips write if content with the same integrity already exists + * (via INSERT OR IGNORE). + * + * @param {string} integrity SRI integrity string of the uncompressed content + * @param {Buffer} buffer Uncompressed resource content + */ + put(integrity, buffer) { + const compressedBuffer = gzipSync(buffer); + this.#stmts.write.run(integrity, compressedBuffer); + } + + /** + * Reads the raw compressed BLOB from the CAS + * + * Useful when the caller needs synchronous access (e.g., for createStream callbacks). + * + * @param {string} integrity SRI integrity string + * @returns {Buffer} Compressed content buffer + */ + readContentRaw(integrity) { + const row = this.#stmts.read.get(integrity); + if (!row) { + throw new Error(`Content not found in CAS for integrity: ${integrity}`); + } + return row.data; + } + + /** + * Reads and decompresses content from the CAS + * + * @param {string} integrity SRI integrity string + * @returns {Buffer} Decompressed content buffer + */ + readContent(integrity) { + return gunzipSync(this.readContentRaw(integrity)); + } + + /** + * Begins a batch transaction for multiple writes + */ + beginBatch() { + if (!this.#inBatch) { + this.#db.exec("BEGIN"); + this.#inBatch = true; + } + } + + /** + * Commits the current batch transaction + */ + endBatch() { + if (this.#inBatch) { + this.#db.exec("COMMIT"); + this.#inBatch = false; + } + } + + /** + * Rolls back the current batch transaction + */ + rollbackBatch() { + if (this.#inBatch) { + this.#db.exec("ROLLBACK"); + this.#inBatch = false; + } + } + + /** + * Closes the database connection + */ + close() { + if (this.#inBatch) { + this.rollbackBatch(); + } + this.#db.close(); + } +} diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index a7bc4d884ab..b543d5c0c0f 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -1,10 +1,8 @@ import {createResource, createProxy, createWriterCollection} from "@ui5/fs/resourceFactory"; import {getLogger} from "@ui5/logger"; -import fs from "graceful-fs"; -import {promisify} from "node:util"; +import {gunzipSync} from "node:zlib"; +import {Readable} from "node:stream"; import crypto from "node:crypto"; -import {gunzip, createGunzip} from "node:zlib"; -const readFile = promisify(fs.readFile); import BuildTaskCache from "./BuildTaskCache.js"; import StageCache from "./StageCache.js"; import ResourceIndex from "./index/ResourceIndex.js"; @@ -1518,14 +1516,17 @@ export default class ProjectBuildCache { async #writeStageResources(resources, stageId, stageSignature, knownCasIntegrities) { const resourceMetadata = Object.create(null); let casSkipped = 0; + + // Phase 1: Gather resource data (async I/O for integrity and buffer) + const toWrite = []; await Promise.all(resources.map(async (res) => { const integrity = await res.getIntegrity(); - // Skip CAS write if integrity is known to already exist from restored stage metadata if (knownCasIntegrities?.has(integrity)) { casSkipped++; } else { - await this.#cacheManager.writeStageResource(res); + const buffer = await res.getBuffer(); + toWrite.push({integrity, buffer}); } resourceMetadata[res.getOriginalPath()] = { @@ -1535,6 +1536,21 @@ export default class ProjectBuildCache { integrity, }; })); + + // Phase 2: Batch write to SQLite in a single transaction + if (toWrite.length > 0) { + this.#cacheManager.beginContentBatch(); + try { + for (const {integrity, buffer} of toWrite) { + this.#cacheManager.putContent(integrity, buffer); + } + this.#cacheManager.endContentBatch(); + } catch (err) { + this.#cacheManager.rollbackContentBatch(); + throw err; + } + } + if (log.isLevelEnabled("perf") && casSkipped > 0) { log.perf( `#writeStageResources for stage ${stageId}: ` + @@ -1620,23 +1636,18 @@ export default class ProjectBuildCache { `in project ${this.#project.getName()}`); } - // Compute the CAS content path synchronously from the integrity hash. - // No I/O needed — the path is a pure function of the integrity. - const cachePath = this.#cacheManager.contentPath(integrity); - return createResource({ path: virPath, sourceMetadata: { - adapter: "CAS", - fsPath: cachePath, + adapter: "CAS_SQLITE", contentModified: false, }, createStream: () => { - return fs.createReadStream(cachePath).pipe(createGunzip()); + const compressed = this.#cacheManager.readContentRaw(integrity); + return Readable.from(gunzipSync(compressed)); }, - createBuffer: async () => { - const compressedBuffer = await readFile(cachePath); - return await promisify(gunzip)(compressedBuffer); + createBuffer: () => { + return this.#cacheManager.readContent(integrity); }, byteSize: size, lastModified, From 04a6eb1909f305c4705d080526e67312e7828db2 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 27 Apr 2026 18:27:44 +0200 Subject: [PATCH 221/223] perf(project): Add batch transaction support to MetadataStorage Wrap metadata writes in explicit SQLite transactions (BEGIN/COMMIT) to reduce per-statement WAL sync overhead, mirroring the existing batch pattern in ContentAddressableStorageSQLite. ProjectBuildCache .writeCache() now wraps all metadata writes in a single transaction with automatic rollback on error. --- .../project/lib/build/cache/CacheManager.js | 21 +++++++++ .../lib/build/cache/MetadataStorage.js | 34 ++++++++++++++ .../lib/build/cache/ProjectBuildCache.js | 23 +++++---- .../test/lib/build/cache/MetadataStorage.js | 47 +++++++++++++++++++ 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 41576298f1c..8cdca5ece9f 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -297,6 +297,27 @@ export default class CacheManager { this.#cas.rollbackBatch(); } + /** + * Begins a batch transaction for multiple metadata writes + */ + beginMetadataBatch() { + this.#metadataStorage.beginBatch(); + } + + /** + * Commits the current metadata batch transaction + */ + endMetadataBatch() { + this.#metadataStorage.endBatch(); + } + + /** + * Rolls back the current metadata batch transaction + */ + rollbackMetadataBatch() { + this.#metadataStorage.rollbackBatch(); + } + /** * Closes the metadata storage */ diff --git a/packages/project/lib/build/cache/MetadataStorage.js b/packages/project/lib/build/cache/MetadataStorage.js index 40c0c0d8306..274b6acc107 100644 --- a/packages/project/lib/build/cache/MetadataStorage.js +++ b/packages/project/lib/build/cache/MetadataStorage.js @@ -17,6 +17,7 @@ export default class MetadataStorage { #db; #stmts; #dbPath; + #inBatch = false; /** * @param {string} dbDir Directory in which to create the metadata.db file @@ -123,10 +124,43 @@ export default class MetadataStorage { return this.#db.isOpen && existsSync(this.#dbPath); } + /** + * Begins a batch transaction for multiple writes + */ + beginBatch() { + if (!this.#inBatch) { + this.#db.exec("BEGIN"); + this.#inBatch = true; + } + } + + /** + * Commits the current batch transaction + */ + endBatch() { + if (this.#inBatch) { + this.#db.exec("COMMIT"); + this.#inBatch = false; + } + } + + /** + * Rolls back the current batch transaction + */ + rollbackBatch() { + if (this.#inBatch) { + this.#db.exec("ROLLBACK"); + this.#inBatch = false; + } + } + /** * Closes the database connection */ close() { + if (this.#inBatch) { + this.rollbackBatch(); + } this.#db.close(); } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index b543d5c0c0f..97370992380 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -1391,14 +1391,21 @@ export default class ProjectBuildCache { */ async writeCache() { const cacheWriteStart = performance.now(); - await Promise.all([ - this.#writeResultCache(), - - this.#writeTaskStageCache(), - this.#writeTaskRequestCache(), - - this.#writeSourceIndex(), - ]); + this.#cacheManager.beginMetadataBatch(); + try { + await Promise.all([ + this.#writeResultCache(), + + this.#writeTaskStageCache(), + this.#writeTaskRequestCache(), + + this.#writeSourceIndex(), + ]); + this.#cacheManager.endMetadataBatch(); + } catch (err) { + this.#cacheManager.rollbackMetadataBatch(); + throw err; + } if (log.isLevelEnabled("perf")) { log.perf( `Wrote build cache for project ${this.#project.getName()} in ` + diff --git a/packages/project/test/lib/build/cache/MetadataStorage.js b/packages/project/test/lib/build/cache/MetadataStorage.js index 3ccba694598..b2629a22b84 100644 --- a/packages/project/test/lib/build/cache/MetadataStorage.js +++ b/packages/project/test/lib/build/cache/MetadataStorage.js @@ -170,3 +170,50 @@ test("Read throws wrapped error after close", (t) => { t.true(err.message.includes("Failed to read resource index cache")); t.truthy(err.cause); }); + +// Batch transactions + +test("beginBatch/endBatch: Multiple writes commit atomically", (t) => { + const {storage} = t.context; + storage.beginBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + storage.writeTaskMetadata("project-a", "sig-1", "minify", "project", {v: 2}); + storage.writeResultMetadata("project-a", "sig-1", "result-sig-1", {v: 3}); + storage.endBatch(); + + t.deepEqual(storage.readIndexCache("project-a", "sig-1", "source"), {v: 1}); + t.deepEqual(storage.readTaskMetadata("project-a", "sig-1", "minify", "project"), {v: 2}); + t.deepEqual(storage.readResultMetadata("project-a", "sig-1", "result-sig-1"), {v: 3}); +}); + +test("rollbackBatch: Discards uncommitted writes", (t) => { + const {storage} = t.context; + storage.beginBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + storage.writeTaskMetadata("project-a", "sig-1", "minify", "project", {v: 2}); + storage.rollbackBatch(); + + t.is(storage.readIndexCache("project-a", "sig-1", "source"), null); + t.is(storage.readTaskMetadata("project-a", "sig-1", "minify", "project"), null); +}); + +test("close: Rolls back uncommitted batch", (t) => { + const {storage} = t.context; + storage.beginBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + storage.close(); + + const fresh = new MetadataStorage(t.context.dbDir); + t.is(fresh.readIndexCache("project-a", "sig-1", "source"), null); + fresh.close(); +}); + +test("beginBatch: Nested calls are idempotent", (t) => { + const {storage} = t.context; + storage.beginBatch(); + storage.beginBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + storage.endBatch(); + + t.deepEqual(storage.readIndexCache("project-a", "sig-1", "source"), {v: 1}); +}); From ecc717daddae04c37c8397a2156d69f3ab7cc63e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 28 Apr 2026 12:56:00 +0200 Subject: [PATCH 222/223] refactor(project): Merge CAS and metadata into unified BuildCacheStorage Replace ContentAddressableStorageSQLite and MetadataStorage with a single BuildCacheStorage class backed by one SQLite database (cache.db). Remove the dead file-based ContentAddressableStorage. Content batches use SAVEPOINTs when nested inside metadata batches, preserving the existing independent rollback semantics with a single DB connection. Bump CACHE_VERSION to v0_6. --- .../skills/incremental-build/architecture.md | 38 +--- ...etadataStorage.js => BuildCacheStorage.js} | 195 +++++++++++++--- .../project/lib/build/cache/CacheManager.js | 71 +++--- .../build/cache/ContentAddressableStorage.js | 142 ------------ .../cache/ContentAddressableStorageSQLite.js | 163 -------------- ...etadataStorage.js => BuildCacheStorage.js} | 208 +++++++++++++++--- .../test/lib/build/cache/CacheManager.js | 22 +- .../build/cache/ContentAddressableStorage.js | 171 -------------- .../test/lib/build/cache/ProjectBuildCache.js | 37 ++-- 9 files changed, 420 insertions(+), 627 deletions(-) rename packages/project/lib/build/cache/{MetadataStorage.js => BuildCacheStorage.js} (65%) delete mode 100644 packages/project/lib/build/cache/ContentAddressableStorage.js delete mode 100644 packages/project/lib/build/cache/ContentAddressableStorageSQLite.js rename packages/project/test/lib/build/cache/{MetadataStorage.js => BuildCacheStorage.js} (52%) delete mode 100644 packages/project/test/lib/build/cache/ContentAddressableStorage.js diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index fe69b380288..779d7b2c68e 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -41,8 +41,8 @@ Use this table to locate source files. ALWAYS read the relevant source file befo | `ProjectBuildCache` | `lib/build/cache/ProjectBuildCache.js` | Cache orchestration per project: index management, stage lookup, result recording | | `BuildTaskCache` | `lib/build/cache/BuildTaskCache.js` | Per-task resource request tracking and index management | | `StageCache` | `lib/build/cache/StageCache.js` | In-memory cache of stage results keyed by signature | -| `ContentAddressableStorage` | `lib/build/cache/ContentAddressableStorage.js` | Custom CAS with synchronous path resolution from integrity hash | -| `CacheManager` | `lib/build/cache/CacheManager.js` | Persistent cache I/O (filesystem), delegates content storage to CAS | +| `BuildCacheStorage` | `lib/build/cache/BuildCacheStorage.js` | Unified SQLite storage for content (CAS) and metadata | +| `CacheManager` | `lib/build/cache/CacheManager.js` | Persistent cache I/O, delegates to BuildCacheStorage | | `ResourceRequestManager` | `lib/build/cache/ResourceRequestManager.js` | Request graph, resource index updates, signature computation | | `ResourceRequestGraph` | `lib/build/cache/ResourceRequestGraph.js` | DAG of request sets with delta encoding and best-parent optimization | | `ResourceIndex` | `lib/build/cache/index/ResourceIndex.js` | Wrapper around hash trees with delta detection | @@ -328,32 +328,14 @@ project.getProjectResources().setStage(stageName, stageCache.stage, ### On Disk (CacheManager) ``` -~/.ui5/buildCache/v0_3/ -+-- cas/ # Custom CAS (ContentAddressableStorage, gzip) -| +-- sha256/ # Resources stored by integrity hash -| +-- {xx}/ # First 2 hex chars of digest -| +-- {yy}/ # Next 2 hex chars -| +-- {rest} # Remaining hex chars (gzip-compressed content) -+-- buildManifests/ # Build metadata per project (plain JSON) -| +-- {projectId}/ -| +-- {buildSignature}.json -+-- stageMetadata/ -| +-- {projectId}/ -| +-- {buildSignature}/ -| +-- {stageId}/ -| +-- {stageSignature}.json -+-- taskMetadata/ -| +-- {projectId}/ -| +-- {buildSignature}/ -| +-- {taskName}/ -| +-- {type}.json # type = "project" | "dependency" -+-- resultMetadata/ -| +-- {projectId}/ -| +-- {buildSignature}/ -| +-- {resultSignature}.json -+-- index/ - +-- {projectId}/ - +-- {kind}-{buildSignature}.json # kind = "source" | "result" +~/.ui5/buildCache/v0_6/ ++-- cache.db # Single SQLite database (WAL mode) + Tables: + - content(integrity TEXT PK, data BLOB) # Gzip-compressed CAS BLOBs + - index_cache(project_id, build_signature, kind, data) + - stage_metadata(project_id, build_signature, stage_id, stage_signature, data) + - task_metadata(project_id, build_signature, task_name, type, data) + - result_metadata(project_id, build_signature, stage_signature, data) ``` Note: Only CAS content (resource bodies) is gzip-compressed. All metadata files are plain JSON. diff --git a/packages/project/lib/build/cache/MetadataStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js similarity index 65% rename from packages/project/lib/build/cache/MetadataStorage.js rename to packages/project/lib/build/cache/BuildCacheStorage.js index 274b6acc107..4e02888d54b 100644 --- a/packages/project/lib/build/cache/MetadataStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -1,36 +1,39 @@ import {DatabaseSync} from "node:sqlite"; import {mkdirSync, existsSync} from "node:fs"; import path from "node:path"; +import {gzipSync, gunzipSync} from "node:zlib"; import {getLogger} from "@ui5/logger"; -const log = getLogger("build:cache:MetadataStorage"); +const log = getLogger("build:cache:BuildCacheStorage"); /** - * SQLite-backed metadata storage for the build cache + * Unified SQLite-backed storage for the build cache * - * Stores build metadata (index caches, stage metadata, task metadata, result metadata) - * as JSON blobs in a single SQLite database keyed by composite primary keys. + * Stores both metadata (index caches, stage metadata, task metadata, result metadata) + * and content-addressable resource content (gzip-compressed BLOBs) in a single database. * * @class */ -export default class MetadataStorage { +export default class BuildCacheStorage { #db; #stmts; #dbPath; - #inBatch = false; + #inMetadataBatch = false; + #inContentBatch = false; /** - * @param {string} dbDir Directory in which to create the metadata.db file + * @param {string} dbDir Directory in which to create the cache.db file */ constructor(dbDir) { mkdirSync(dbDir, {recursive: true}); - this.#dbPath = path.join(dbDir, "metadata.db"); - log.verbose(`Opening metadata database: ${this.#dbPath}`); + this.#dbPath = path.join(dbDir, "cache.db"); + log.verbose(`Opening build cache database: ${this.#dbPath}`); this.#db = new DatabaseSync(this.#dbPath); this.#db.exec("PRAGMA journal_mode=WAL"); this.#db.exec("PRAGMA synchronous=NORMAL"); this.#db.exec("PRAGMA busy_timeout=5000"); + this.#db.exec("PRAGMA page_size=8192"); this.#createTables(); this.#prepareStatements(); @@ -38,6 +41,11 @@ export default class MetadataStorage { #createTables() { this.#db.exec(` + CREATE TABLE IF NOT EXISTS content ( + integrity TEXT PRIMARY KEY, + data BLOB NOT NULL + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS index_cache ( project_id TEXT NOT NULL, build_signature TEXT NOT NULL, @@ -76,6 +84,18 @@ export default class MetadataStorage { #prepareStatements() { this.#stmts = { + // Content (CAS) + hasContent: this.#db.prepare( + "SELECT 1 FROM content WHERE integrity = ?" + ), + readContent: this.#db.prepare( + "SELECT data FROM content WHERE integrity = ?" + ), + writeContent: this.#db.prepare( + "INSERT OR IGNORE INTO content (integrity, data) VALUES (?, ?)" + ), + + // Index cache readIndexCache: this.#db.prepare( "SELECT data FROM index_cache WHERE project_id = ? AND build_signature = ? AND kind = ?" ), @@ -84,6 +104,7 @@ export default class MetadataStorage { VALUES (?, ?, ?, ?)` ), + // Stage metadata readStageMetadata: this.#db.prepare( `SELECT data FROM stage_metadata WHERE project_id = ? AND build_signature = ? AND stage_id = ? AND stage_signature = ?` @@ -93,6 +114,7 @@ export default class MetadataStorage { (project_id, build_signature, stage_id, stage_signature, data) VALUES (?, ?, ?, ?, ?)` ), + // Task metadata readTaskMetadata: this.#db.prepare( `SELECT data FROM task_metadata WHERE project_id = ? AND build_signature = ? AND task_name = ? AND type = ?` @@ -102,6 +124,7 @@ export default class MetadataStorage { (project_id, build_signature, task_name, type, data) VALUES (?, ?, ?, ?, ?)` ), + // Result metadata readResultMetadata: this.#db.prepare( `SELECT data FROM result_metadata WHERE project_id = ? AND build_signature = ? AND stage_signature = ?` @@ -115,8 +138,6 @@ export default class MetadataStorage { /** * Whether the database connection is open and the database file still exists on disk. - * This detects cases where the cache directory was deleted externally - * (e.g., by test cleanup) while the connection was still open. * * @returns {boolean} */ @@ -124,46 +145,58 @@ export default class MetadataStorage { return this.#db.isOpen && existsSync(this.#dbPath); } + // ===== Content (CAS) operations ===== + /** - * Begins a batch transaction for multiple writes + * Checks whether content with the given integrity exists in storage + * + * @param {string} integrity SRI integrity string + * @returns {boolean} True if content exists */ - beginBatch() { - if (!this.#inBatch) { - this.#db.exec("BEGIN"); - this.#inBatch = true; - } + hasContent(integrity) { + return this.#stmts.hasContent.get(integrity) !== undefined; } /** - * Commits the current batch transaction + * Stores resource content in the CAS + * + * Compresses the buffer with gzip and stores it as a BLOB. + * Deduplicates via INSERT OR IGNORE. + * + * @param {string} integrity SRI integrity string of the uncompressed content + * @param {Buffer} buffer Uncompressed resource content */ - endBatch() { - if (this.#inBatch) { - this.#db.exec("COMMIT"); - this.#inBatch = false; - } + putContent(integrity, buffer) { + const compressedBuffer = gzipSync(buffer); + this.#stmts.writeContent.run(integrity, compressedBuffer); } /** - * Rolls back the current batch transaction + * Reads the raw compressed BLOB from the CAS + * + * @param {string} integrity SRI integrity string + * @returns {Buffer} Compressed content buffer */ - rollbackBatch() { - if (this.#inBatch) { - this.#db.exec("ROLLBACK"); - this.#inBatch = false; + readContentRaw(integrity) { + const row = this.#stmts.readContent.get(integrity); + if (!row) { + throw new Error(`Content not found in CAS for integrity: ${integrity}`); } + return row.data; } /** - * Closes the database connection + * Reads and decompresses content from the CAS + * + * @param {string} integrity SRI integrity string + * @returns {Buffer} Decompressed content buffer */ - close() { - if (this.#inBatch) { - this.rollbackBatch(); - } - this.#db.close(); + readContent(integrity) { + return gunzipSync(this.readContentRaw(integrity)); } + // ===== Metadata operations ===== + /** * Reads resource index cache * @@ -311,4 +344,98 @@ export default class MetadataStorage { projectId, buildSignature, stageSignature, JSON.stringify(metadata) ); } + + // ===== Batch transactions ===== + + /** + * Begins a metadata batch transaction (outer transaction) + */ + beginMetadataBatch() { + if (!this.#inMetadataBatch) { + this.#db.exec("BEGIN"); + this.#inMetadataBatch = true; + } + } + + /** + * Commits the current metadata batch transaction + */ + endMetadataBatch() { + if (this.#inMetadataBatch) { + this.#db.exec("COMMIT"); + this.#inMetadataBatch = false; + } + } + + /** + * Rolls back the current metadata batch transaction + */ + rollbackMetadataBatch() { + if (this.#inMetadataBatch) { + this.#db.exec("ROLLBACK"); + this.#inMetadataBatch = false; + } + } + + /** + * Begins a content batch transaction. + * Uses SAVEPOINT when nested inside a metadata batch, plain BEGIN otherwise. + */ + beginContentBatch() { + if (this.#inContentBatch) { + return; + } + if (this.#inMetadataBatch) { + this.#db.exec("SAVEPOINT content_batch"); + } else { + this.#db.exec("BEGIN"); + } + this.#inContentBatch = true; + } + + /** + * Commits the current content batch transaction. + * Uses RELEASE when nested inside a metadata batch, plain COMMIT otherwise. + */ + endContentBatch() { + if (!this.#inContentBatch) { + return; + } + if (this.#inMetadataBatch) { + this.#db.exec("RELEASE content_batch"); + } else { + this.#db.exec("COMMIT"); + } + this.#inContentBatch = false; + } + + /** + * Rolls back the current content batch transaction. + * Uses ROLLBACK TO + RELEASE when nested inside a metadata batch, plain ROLLBACK otherwise. + */ + rollbackContentBatch() { + if (!this.#inContentBatch) { + return; + } + if (this.#inMetadataBatch) { + this.#db.exec("ROLLBACK TO content_batch"); + this.#db.exec("RELEASE content_batch"); + } else { + this.#db.exec("ROLLBACK"); + } + this.#inContentBatch = false; + } + + /** + * Closes the database connection + */ + close() { + if (this.#inContentBatch) { + this.rollbackContentBatch(); + } + if (this.#inMetadataBatch) { + this.rollbackMetadataBatch(); + } + this.#db.close(); + } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 8cdca5ece9f..ad1007cbcf9 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -2,8 +2,7 @@ import path from "node:path"; import os from "node:os"; import Configuration from "../../config/Configuration.js"; import {getLogger} from "@ui5/logger"; -import ContentAddressableStorageSQLite from "./ContentAddressableStorageSQLite.js"; -import MetadataStorage from "./MetadataStorage.js"; +import BuildCacheStorage from "./BuildCacheStorage.js"; const log = getLogger("build:cache:CacheManager"); @@ -11,15 +10,15 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -const CACHE_VERSION = "v0_5"; +const CACHE_VERSION = "v0_6"; /** - * Manages persistence for the build cache using SQLite-backed metadata storage - * and a content-addressable storage (CAS) for resource content + * Manages persistence for the build cache using a unified SQLite-backed storage + * for both metadata and content-addressable resource content * * CacheManager delegates metadata operations (index caches, stage metadata, - * task metadata, result metadata) to MetadataStorage (SQLite), and binary - * resource content to ContentAddressableStorage (file-based, gzip-compressed). + * task metadata, result metadata) and binary resource content (gzip-compressed + * BLOBs) to BuildCacheStorage (single SQLite database). * * The cache is organized by: * 1. Project ID (package name) @@ -27,17 +26,15 @@ const CACHE_VERSION = "v0_5"; * 3. Stage/task identifiers and signatures * * Key features: - * - Content-addressable storage with synchronous path resolution + * - Content-addressable storage with deduplication * - Singleton pattern per cache directory * - Configurable cache location via UI5_DATA_DIR or configuration - * - Efficient resource deduplication through content-addressable storage - * - SQLite-backed metadata for fast read/write operations + * - SQLite-backed storage for fast read/write operations * * @class */ export default class CacheManager { - #cas; - #metadataStorage; + #storage; /** * Creates a new CacheManager instance @@ -47,12 +44,11 @@ export default class CacheManager { */ constructor(cacheDir) { const versionedDir = path.join(cacheDir, CACHE_VERSION); - this.#cas = new ContentAddressableStorageSQLite(path.join(versionedDir, "content.db")); - this.#metadataStorage = new MetadataStorage(versionedDir); + this.#storage = new BuildCacheStorage(versionedDir); } #isValid() { - return this.#metadataStorage.isValid && this.#cas.isValid; + return this.#storage.isValid; } /** @@ -99,7 +95,7 @@ export default class CacheManager { * @returns {Promise} Parsed index cache object or null if not found */ async readIndexCache(projectId, buildSignature, kind) { - return this.#metadataStorage.readIndexCache(projectId, buildSignature, kind); + return this.#storage.readIndexCache(projectId, buildSignature, kind); } /** @@ -113,7 +109,7 @@ export default class CacheManager { * @returns {Promise} */ async writeIndexCache(projectId, buildSignature, kind, index) { - this.#metadataStorage.writeIndexCache(projectId, buildSignature, kind, index); + this.#storage.writeIndexCache(projectId, buildSignature, kind, index); } /** @@ -127,7 +123,7 @@ export default class CacheManager { * @returns {Promise} Parsed stage metadata or null if not found */ async readStageCache(projectId, buildSignature, stageId, stageSignature) { - return this.#metadataStorage.readStageCache(projectId, buildSignature, stageId, stageSignature); + return this.#storage.readStageCache(projectId, buildSignature, stageId, stageSignature); } /** @@ -142,7 +138,7 @@ export default class CacheManager { * @returns {Promise} */ async writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata) { - this.#metadataStorage.writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata); + this.#storage.writeStageCache(projectId, buildSignature, stageId, stageSignature, metadata); } /** @@ -156,7 +152,7 @@ export default class CacheManager { * @returns {Promise} Parsed task metadata or null if not found */ async readTaskMetadata(projectId, buildSignature, taskName, type) { - return this.#metadataStorage.readTaskMetadata(projectId, buildSignature, taskName, type); + return this.#storage.readTaskMetadata(projectId, buildSignature, taskName, type); } /** @@ -171,7 +167,7 @@ export default class CacheManager { * @returns {Promise} */ async writeTaskMetadata(projectId, buildSignature, taskName, type, metadata) { - this.#metadataStorage.writeTaskMetadata(projectId, buildSignature, taskName, type, metadata); + this.#storage.writeTaskMetadata(projectId, buildSignature, taskName, type, metadata); } /** @@ -184,7 +180,7 @@ export default class CacheManager { * @returns {Promise} Parsed result metadata or null if not found */ async readResultMetadata(projectId, buildSignature, stageSignature) { - return this.#metadataStorage.readResultMetadata(projectId, buildSignature, stageSignature); + return this.#storage.readResultMetadata(projectId, buildSignature, stageSignature); } /** @@ -198,7 +194,7 @@ export default class CacheManager { * @returns {Promise} */ async writeResultMetadata(projectId, buildSignature, stageSignature, metadata) { - this.#metadataStorage.writeResultMetadata(projectId, buildSignature, stageSignature, metadata); + this.#storage.writeResultMetadata(projectId, buildSignature, stageSignature, metadata); } /** @@ -209,7 +205,7 @@ export default class CacheManager { * @returns {boolean} True if content exists */ hasContent(integrity) { - return this.#cas.has(integrity); + return this.#storage.hasContent(integrity); } /** @@ -220,7 +216,7 @@ export default class CacheManager { * @returns {Buffer} Decompressed content buffer */ readContent(integrity) { - return this.#cas.readContent(integrity); + return this.#storage.readContent(integrity); } /** @@ -231,7 +227,7 @@ export default class CacheManager { * @returns {Buffer} Compressed content buffer */ readContentRaw(integrity) { - return this.#cas.readContentRaw(integrity); + return this.#storage.readContentRaw(integrity); } /** @@ -246,7 +242,7 @@ export default class CacheManager { if (!integrity) { throw new Error("Integrity hash must be provided to read from cache"); } - return this.#cas.has(integrity); + return this.#storage.hasContent(integrity); } /** @@ -262,7 +258,7 @@ export default class CacheManager { async writeStageResource(resource) { const integrity = await resource.getIntegrity(); const buffer = await resource.getBuffer(); - this.#cas.put(integrity, buffer); + this.#storage.putContent(integrity, buffer); } /** @@ -273,56 +269,55 @@ export default class CacheManager { * @param {Buffer} buffer Uncompressed resource content */ putContent(integrity, buffer) { - this.#cas.put(integrity, buffer); + this.#storage.putContent(integrity, buffer); } /** * Begins a batch transaction for multiple content writes */ beginContentBatch() { - this.#cas.beginBatch(); + this.#storage.beginContentBatch(); } /** * Commits the current content batch transaction */ endContentBatch() { - this.#cas.endBatch(); + this.#storage.endContentBatch(); } /** * Rolls back the current content batch transaction */ rollbackContentBatch() { - this.#cas.rollbackBatch(); + this.#storage.rollbackContentBatch(); } /** * Begins a batch transaction for multiple metadata writes */ beginMetadataBatch() { - this.#metadataStorage.beginBatch(); + this.#storage.beginMetadataBatch(); } /** * Commits the current metadata batch transaction */ endMetadataBatch() { - this.#metadataStorage.endBatch(); + this.#storage.endMetadataBatch(); } /** * Rolls back the current metadata batch transaction */ rollbackMetadataBatch() { - this.#metadataStorage.rollbackBatch(); + this.#storage.rollbackMetadataBatch(); } /** - * Closes the metadata storage + * Closes the storage */ close() { - this.#cas.close(); - this.#metadataStorage.close(); + this.#storage.close(); } } diff --git a/packages/project/lib/build/cache/ContentAddressableStorage.js b/packages/project/lib/build/cache/ContentAddressableStorage.js deleted file mode 100644 index c874064fd2a..00000000000 --- a/packages/project/lib/build/cache/ContentAddressableStorage.js +++ /dev/null @@ -1,142 +0,0 @@ -import ssri from "ssri"; -import path from "node:path"; -import fs from "graceful-fs"; -import {promisify} from "node:util"; -import {gzip, gunzip, createGunzip} from "node:zlib"; -const mkdir = promisify(fs.mkdir); -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); -const rename = promisify(fs.rename); -const access = promisify(fs.access); -const unlink = promisify(fs.unlink); - -let tmpCounter = 0; - -/** - * Content-addressable storage for build cache resources - * - * Stores gzip-compressed content keyed by the original resource integrity hash. - * The filesystem path is a pure function of the integrity hash, enabling - * synchronous path resolution without index lookups. - * - * Directory structure: - * {basePath}/{algorithm}/{xx}/{yy}/{rest} - * - * For example, integrity "sha256-abc123..." with hex digest "abcdef0123456789..." becomes: - * {basePath}/sha256/ab/cd/ef0123456789... - * - * @class - */ -export default class ContentAddressableStorage { - #basePath; - - /** - * @param {string} basePath Base directory for content storage - */ - constructor(basePath) { - this.#basePath = basePath; - } - - /** - * Computes the filesystem path for a given integrity hash - * - * This is a synchronous, pure function with no I/O. - * - * @param {string} integrity SRI integrity string (e.g., "sha256-base64encoded=") - * @returns {string} Absolute filesystem path to the content file - */ - contentPath(integrity) { - const sri = ssri.parse(integrity, {single: true}); - const hex = sri.hexDigest(); - return path.join( - this.#basePath, - sri.algorithm, - hex.slice(0, 2), - hex.slice(2, 4), - hex.slice(4) - ); - } - - /** - * Checks whether content with the given integrity exists in storage - * - * @param {string} integrity SRI integrity string - * @returns {Promise} True if content exists - */ - async has(integrity) { - try { - await access(this.contentPath(integrity)); - return true; - } catch { - return false; - } - } - - /** - * Stores resource content in the CAS - * - * Compresses the buffer with gzip and writes it atomically (tmp + rename). - * Deduplicates: skips write if content with the same integrity already exists. - * - * @param {string} integrity SRI integrity string of the uncompressed content - * @param {Buffer} buffer Uncompressed resource content - * @returns {Promise} - */ - async put(integrity, buffer) { - const contentPath = this.contentPath(integrity); - - // Dedup: skip if content already exists - if (await this.has(integrity)) { - return; - } - - const compressedBuffer = await promisify(gzip)(buffer); - const dirPath = path.dirname(contentPath); - await mkdir(dirPath, {recursive: true}); - - // Atomic write: write to temp file then rename. - // Use a unique counter to avoid collisions between concurrent puts. - const tmpPath = contentPath + `.tmp.${process.pid}.${tmpCounter++}`; - try { - await writeFile(tmpPath, compressedBuffer); - await rename(tmpPath, contentPath); - } catch (err) { - // Clean up tmp file on failure (best effort) - try { - await unlink(tmpPath); - } catch { - // tmp file already gone (e.g., concurrent rename succeeded first) - } - // If the content now exists (written by a concurrent put), that's fine - if (await this.has(integrity)) { - return; - } - throw err; - } - } - - /** - * Creates a readable stream that decompresses content from the CAS - * - * This is synchronous — the stream is returned immediately. - * - * @param {string} integrity SRI integrity string - * @returns {import("node:stream").Readable} Decompressed content stream - */ - createReadStream(integrity) { - const contentPath = this.contentPath(integrity); - return fs.createReadStream(contentPath).pipe(createGunzip()); - } - - /** - * Reads and decompresses content from the CAS - * - * @param {string} integrity SRI integrity string - * @returns {Promise} Decompressed content buffer - */ - async readContent(integrity) { - const contentPath = this.contentPath(integrity); - const compressedBuffer = await readFile(contentPath); - return await promisify(gunzip)(compressedBuffer); - } -} diff --git a/packages/project/lib/build/cache/ContentAddressableStorageSQLite.js b/packages/project/lib/build/cache/ContentAddressableStorageSQLite.js deleted file mode 100644 index 63daafcb5cc..00000000000 --- a/packages/project/lib/build/cache/ContentAddressableStorageSQLite.js +++ /dev/null @@ -1,163 +0,0 @@ -import {DatabaseSync} from "node:sqlite"; -import {mkdirSync, existsSync} from "node:fs"; -import path from "node:path"; -import {gzipSync, gunzipSync} from "node:zlib"; -import {getLogger} from "@ui5/logger"; - -const log = getLogger("build:cache:ContentAddressableStorageSQLite"); - -/** - * SQLite-backed content-addressable storage for build cache resources - * - * Stores gzip-compressed content as BLOBs keyed by the original resource - * integrity hash. All reads and writes use synchronous DatabaseSync operations. - * - * @class - */ -export default class ContentAddressableStorageSQLite { - #db; - #stmts; - #dbPath; - #inBatch = false; - - /** - * @param {string} dbPath Path to the SQLite database file - */ - constructor(dbPath) { - mkdirSync(path.dirname(dbPath), {recursive: true}); - this.#dbPath = dbPath; - log.verbose(`Opening content database: ${this.#dbPath}`); - - this.#db = new DatabaseSync(this.#dbPath); - this.#db.exec("PRAGMA journal_mode=WAL"); - this.#db.exec("PRAGMA synchronous=NORMAL"); - this.#db.exec("PRAGMA busy_timeout=5000"); - this.#db.exec("PRAGMA page_size=8192"); - - this.#createTable(); - this.#prepareStatements(); - } - - #createTable() { - this.#db.exec(` - CREATE TABLE IF NOT EXISTS content ( - integrity TEXT PRIMARY KEY, - data BLOB NOT NULL - ) WITHOUT ROWID; - `); - } - - #prepareStatements() { - this.#stmts = { - has: this.#db.prepare( - "SELECT 1 FROM content WHERE integrity = ?" - ), - read: this.#db.prepare( - "SELECT data FROM content WHERE integrity = ?" - ), - write: this.#db.prepare( - "INSERT OR IGNORE INTO content (integrity, data) VALUES (?, ?)" - ), - }; - } - - /** - * Whether the database connection is open and the database file still exists on disk. - * - * @returns {boolean} - */ - get isValid() { - return this.#db.isOpen && existsSync(this.#dbPath); - } - - /** - * Checks whether content with the given integrity exists in storage - * - * @param {string} integrity SRI integrity string - * @returns {boolean} True if content exists - */ - has(integrity) { - return this.#stmts.has.get(integrity) !== undefined; - } - - /** - * Stores resource content in the CAS - * - * Compresses the buffer with gzip and stores it as a BLOB. - * Deduplicates: skips write if content with the same integrity already exists - * (via INSERT OR IGNORE). - * - * @param {string} integrity SRI integrity string of the uncompressed content - * @param {Buffer} buffer Uncompressed resource content - */ - put(integrity, buffer) { - const compressedBuffer = gzipSync(buffer); - this.#stmts.write.run(integrity, compressedBuffer); - } - - /** - * Reads the raw compressed BLOB from the CAS - * - * Useful when the caller needs synchronous access (e.g., for createStream callbacks). - * - * @param {string} integrity SRI integrity string - * @returns {Buffer} Compressed content buffer - */ - readContentRaw(integrity) { - const row = this.#stmts.read.get(integrity); - if (!row) { - throw new Error(`Content not found in CAS for integrity: ${integrity}`); - } - return row.data; - } - - /** - * Reads and decompresses content from the CAS - * - * @param {string} integrity SRI integrity string - * @returns {Buffer} Decompressed content buffer - */ - readContent(integrity) { - return gunzipSync(this.readContentRaw(integrity)); - } - - /** - * Begins a batch transaction for multiple writes - */ - beginBatch() { - if (!this.#inBatch) { - this.#db.exec("BEGIN"); - this.#inBatch = true; - } - } - - /** - * Commits the current batch transaction - */ - endBatch() { - if (this.#inBatch) { - this.#db.exec("COMMIT"); - this.#inBatch = false; - } - } - - /** - * Rolls back the current batch transaction - */ - rollbackBatch() { - if (this.#inBatch) { - this.#db.exec("ROLLBACK"); - this.#inBatch = false; - } - } - - /** - * Closes the database connection - */ - close() { - if (this.#inBatch) { - this.rollbackBatch(); - } - this.#db.close(); - } -} diff --git a/packages/project/test/lib/build/cache/MetadataStorage.js b/packages/project/test/lib/build/cache/BuildCacheStorage.js similarity index 52% rename from packages/project/test/lib/build/cache/MetadataStorage.js rename to packages/project/test/lib/build/cache/BuildCacheStorage.js index b2629a22b84..adfd2cdc8b0 100644 --- a/packages/project/test/lib/build/cache/MetadataStorage.js +++ b/packages/project/test/lib/build/cache/BuildCacheStorage.js @@ -2,9 +2,10 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs"; import {rimraf} from "rimraf"; -import MetadataStorage from "../../../../lib/build/cache/MetadataStorage.js"; +import {gunzipSync} from "node:zlib"; +import BuildCacheStorage from "../../../../lib/build/cache/BuildCacheStorage.js"; -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "MetadataStorage"); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "BuildCacheStorage"); test.after.always(async () => { await rimraf(TEST_DIR); @@ -12,25 +13,84 @@ test.after.always(async () => { test.beforeEach((t) => { t.context.dbDir = path.join(TEST_DIR, `db-${Date.now()}-${Math.random().toString(36).slice(2)}`); - t.context.storage = new MetadataStorage(t.context.dbDir); + t.context.storage = new BuildCacheStorage(t.context.dbDir); }); test.afterEach.always((t) => { try { t.context.storage.close(); } catch { - // Already closed (e.g., in error-handling tests) + // Already closed } }); // Database file creation -test("Creates metadata.db in the specified directory", (t) => { - const dbPath = path.join(t.context.dbDir, "metadata.db"); +test("Creates cache.db in the specified directory", (t) => { + const dbPath = path.join(t.context.dbDir, "cache.db"); t.true(fs.existsSync(dbPath)); }); -// Index cache +// ===== Content (CAS) operations ===== + +test("hasContent: Returns false for missing content", (t) => { + t.false(t.context.storage.hasContent("sha256-missing")); +}); + +test("hasContent: Returns true after content is stored", (t) => { + const content = Buffer.from("test content"); + t.context.storage.putContent("sha256-test", content); + t.true(t.context.storage.hasContent("sha256-test")); +}); + +test("putContent + readContent: Round-trip", (t) => { + const content = Buffer.from("hello world"); + t.context.storage.putContent("sha256-hello", content); + const result = t.context.storage.readContent("sha256-hello"); + t.deepEqual(result, content); +}); + +test("putContent + readContentRaw: Returns gzip-compressed data", (t) => { + const content = Buffer.from("compressed test"); + t.context.storage.putContent("sha256-compressed", content); + const raw = t.context.storage.readContentRaw("sha256-compressed"); + t.notDeepEqual(raw, content); + t.deepEqual(gunzipSync(raw), content); +}); + +test("putContent: Deduplicates via INSERT OR IGNORE", (t) => { + const content1 = Buffer.from("original"); + t.context.storage.putContent("sha256-dedup", content1); + // Second put with same integrity but different buffer is ignored + const content2 = Buffer.from("different"); + t.context.storage.putContent("sha256-dedup", content2); + const result = t.context.storage.readContent("sha256-dedup"); + t.deepEqual(result, content1); +}); + +test("readContent: Throws for missing integrity", (t) => { + t.throws(() => t.context.storage.readContent("sha256-nonexistent"), { + message: /Content not found in CAS for integrity/ + }); +}); + +test("readContentRaw: Throws for missing integrity", (t) => { + t.throws(() => t.context.storage.readContentRaw("sha256-nonexistent"), { + message: /Content not found in CAS for integrity/ + }); +}); + +test("putContent + readContent: Large content round-trip", (t) => { + const content = Buffer.alloc(1024 * 1024); + for (let i = 0; i < content.length; i++) { + content[i] = i % 256; + } + t.context.storage.putContent("sha256-large", content); + const result = t.context.storage.readContent("sha256-large"); + t.deepEqual(result, content); +}); + +// ===== Index cache ===== test("readIndexCache: Returns null on cache miss", (t) => { const result = t.context.storage.readIndexCache("project-a", "sig-1", "source"); @@ -62,7 +122,7 @@ test("Index cache: Overwrite replaces data", (t) => { t.deepEqual(t.context.storage.readIndexCache("project-a", "sig-1", "source"), updated); }); -// Stage metadata +// ===== Stage metadata ===== test("readStageCache: Returns null on cache miss", (t) => { const result = t.context.storage.readStageCache("project-a", "sig-1", "task/minify", "stage-sig-1"); @@ -98,7 +158,7 @@ test("Stage metadata: Stage IDs with slashes are stored correctly", (t) => { ); }); -// Task metadata +// ===== Task metadata ===== test("readTaskMetadata: Returns null on cache miss", (t) => { const result = t.context.storage.readTaskMetadata("project-a", "sig-1", "minify", "project"); @@ -126,7 +186,7 @@ test("Task metadata: Different types are independent", (t) => { ); }); -// Result metadata +// ===== Result metadata ===== test("readResultMetadata: Returns null on cache miss", (t) => { const result = t.context.storage.readResultMetadata("project-a", "sig-1", "result-sig-1"); @@ -148,7 +208,7 @@ test("Result metadata: Overwrite replaces data", (t) => { t.deepEqual(t.context.storage.readResultMetadata("project-a", "sig-1", "result-sig-1"), updated); }); -// Cross-project isolation +// ===== Cross-project isolation ===== test("Different projects are fully isolated", (t) => { const dataA = {project: "a"}; @@ -160,7 +220,7 @@ test("Different projects are fully isolated", (t) => { t.deepEqual(t.context.storage.readIndexCache("project-b", "sig-1", "source"), dataB); }); -// Error handling +// ===== Error handling ===== test("Read throws wrapped error after close", (t) => { t.context.storage.close(); @@ -171,49 +231,145 @@ test("Read throws wrapped error after close", (t) => { t.truthy(err.cause); }); -// Batch transactions +// ===== Metadata batch transactions ===== -test("beginBatch/endBatch: Multiple writes commit atomically", (t) => { +test("beginMetadataBatch/endMetadataBatch: Multiple writes commit atomically", (t) => { const {storage} = t.context; - storage.beginBatch(); + storage.beginMetadataBatch(); storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); storage.writeTaskMetadata("project-a", "sig-1", "minify", "project", {v: 2}); storage.writeResultMetadata("project-a", "sig-1", "result-sig-1", {v: 3}); - storage.endBatch(); + storage.endMetadataBatch(); t.deepEqual(storage.readIndexCache("project-a", "sig-1", "source"), {v: 1}); t.deepEqual(storage.readTaskMetadata("project-a", "sig-1", "minify", "project"), {v: 2}); t.deepEqual(storage.readResultMetadata("project-a", "sig-1", "result-sig-1"), {v: 3}); }); -test("rollbackBatch: Discards uncommitted writes", (t) => { +test("rollbackMetadataBatch: Discards uncommitted writes", (t) => { const {storage} = t.context; - storage.beginBatch(); + storage.beginMetadataBatch(); storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); storage.writeTaskMetadata("project-a", "sig-1", "minify", "project", {v: 2}); - storage.rollbackBatch(); + storage.rollbackMetadataBatch(); t.is(storage.readIndexCache("project-a", "sig-1", "source"), null); t.is(storage.readTaskMetadata("project-a", "sig-1", "minify", "project"), null); }); -test("close: Rolls back uncommitted batch", (t) => { +test("close: Rolls back uncommitted metadata batch", (t) => { const {storage} = t.context; - storage.beginBatch(); + storage.beginMetadataBatch(); storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); storage.close(); - const fresh = new MetadataStorage(t.context.dbDir); + const fresh = new BuildCacheStorage(t.context.dbDir); t.is(fresh.readIndexCache("project-a", "sig-1", "source"), null); fresh.close(); }); -test("beginBatch: Nested calls are idempotent", (t) => { +test("beginMetadataBatch: Nested calls are idempotent", (t) => { + const {storage} = t.context; + storage.beginMetadataBatch(); + storage.beginMetadataBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + storage.endMetadataBatch(); + + t.deepEqual(storage.readIndexCache("project-a", "sig-1", "source"), {v: 1}); +}); + +// ===== Standalone content batch transactions ===== + +test("Standalone content batch: Commit persists content", (t) => { + const {storage} = t.context; + storage.beginContentBatch(); + storage.putContent("sha256-batch1", Buffer.from("batch item 1")); + storage.putContent("sha256-batch2", Buffer.from("batch item 2")); + storage.endContentBatch(); + + t.deepEqual(storage.readContent("sha256-batch1"), Buffer.from("batch item 1")); + t.deepEqual(storage.readContent("sha256-batch2"), Buffer.from("batch item 2")); +}); + +test("Standalone content batch: Rollback discards content", (t) => { + const {storage} = t.context; + storage.beginContentBatch(); + storage.putContent("sha256-rollback", Buffer.from("should be discarded")); + storage.rollbackContentBatch(); + + t.false(storage.hasContent("sha256-rollback")); +}); + +test("beginContentBatch: Nested calls are idempotent", (t) => { const {storage} = t.context; - storage.beginBatch(); - storage.beginBatch(); + storage.beginContentBatch(); + storage.beginContentBatch(); + storage.putContent("sha256-idempotent", Buffer.from("idempotent")); + storage.endContentBatch(); + + t.deepEqual(storage.readContent("sha256-idempotent"), Buffer.from("idempotent")); +}); + +// ===== Nested content batch inside metadata batch (SAVEPOINT) ===== + +test("Nested content batch: Content persists when both batches commit", (t) => { + const {storage} = t.context; + storage.beginMetadataBatch(); storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); - storage.endBatch(); + + storage.beginContentBatch(); + storage.putContent("sha256-nested", Buffer.from("nested content")); + storage.endContentBatch(); + + storage.endMetadataBatch(); + + t.deepEqual(storage.readIndexCache("project-a", "sig-1", "source"), {v: 1}); + t.deepEqual(storage.readContent("sha256-nested"), Buffer.from("nested content")); +}); + +test("Nested content batch rollback: Metadata survives, content is discarded", (t) => { + const {storage} = t.context; + storage.beginMetadataBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + + storage.beginContentBatch(); + storage.putContent("sha256-rolled-back", Buffer.from("will be rolled back")); + storage.rollbackContentBatch(); + + storage.endMetadataBatch(); t.deepEqual(storage.readIndexCache("project-a", "sig-1", "source"), {v: 1}); + t.false(storage.hasContent("sha256-rolled-back")); +}); + +test("Metadata rollback: Both metadata and nested content are discarded", (t) => { + const {storage} = t.context; + storage.beginMetadataBatch(); + storage.writeIndexCache("project-a", "sig-1", "source", {v: 1}); + + storage.beginContentBatch(); + storage.putContent("sha256-outer-rb", Buffer.from("will be discarded")); + storage.endContentBatch(); + + storage.rollbackMetadataBatch(); + + t.is(storage.readIndexCache("project-a", "sig-1", "source"), null); + t.false(storage.hasContent("sha256-outer-rb")); +}); + +// ===== Validity ===== + +test("isValid: Returns true for open database", (t) => { + t.true(t.context.storage.isValid); +}); + +test("isValid: Returns false after close", (t) => { + t.context.storage.close(); + t.false(t.context.storage.isValid); +}); + +test("isValid: Returns false after database file is deleted", (t) => { + const dbPath = path.join(t.context.dbDir, "cache.db"); + fs.unlinkSync(dbPath); + t.false(t.context.storage.isValid); }); diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index 05ada670848..dfc3ffa1f6f 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -84,35 +84,35 @@ test.serial("Cache miss returns null for all metadata types", async (t) => { // CAS delegation -test.serial("contentPath delegates to CAS", async (t) => { +test.serial("Content round-trip via CacheManager", async (t) => { const testDir = getUniqueTestDir(); const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; const cm = new CacheManager(path.join(testDir, "buildCache")); - const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; - const result = cm.contentPath(integrity); - t.is(typeof result, "string"); - t.true(result.includes("cas")); - t.true(result.includes("sha256")); + const content = Buffer.from("test content"); + cm.putContent("sha256-test", content); + t.true(cm.hasContent("sha256-test")); + t.deepEqual(cm.readContent("sha256-test"), content); cm.close(); }); -test.serial("getResourcePathForStage returns null for missing content", async (t) => { +test.serial("hasResourceForStage delegates to content storage", async (t) => { const testDir = getUniqueTestDir(); const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; const cm = new CacheManager(path.join(testDir, "buildCache")); - const result = await cm.getResourcePathForStage("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="); - t.is(result, null); + t.false(cm.hasResourceForStage("sha256-missing")); + cm.putContent("sha256-exists", Buffer.from("data")); + t.true(cm.hasResourceForStage("sha256-exists")); cm.close(); }); -test.serial("getResourcePathForStage throws without integrity", async (t) => { +test.serial("hasResourceForStage throws without integrity", async (t) => { const testDir = getUniqueTestDir(); const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; const cm = new CacheManager(path.join(testDir, "buildCache")); - await t.throwsAsync(cm.getResourcePathForStage(null), { + t.throws(() => cm.hasResourceForStage(null), { message: "Integrity hash must be provided to read from cache" }); cm.close(); diff --git a/packages/project/test/lib/build/cache/ContentAddressableStorage.js b/packages/project/test/lib/build/cache/ContentAddressableStorage.js deleted file mode 100644 index 13867641d2a..00000000000 --- a/packages/project/test/lib/build/cache/ContentAddressableStorage.js +++ /dev/null @@ -1,171 +0,0 @@ -import test from "ava"; -import path from "node:path"; -import fs from "graceful-fs"; -import {promisify} from "node:util"; -import {gunzip} from "node:zlib"; -import {rimraf} from "rimraf"; -import ContentAddressableStorage from "../../../../lib/build/cache/ContentAddressableStorage.js"; - -const readFile = promisify(fs.readFile); - -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "ContentAddressableStorage"); - -test.beforeEach(async (t) => { - t.context.basePath = path.join(TEST_DIR, `cas-${Date.now()}-${Math.random().toString(36).slice(2)}`); - t.context.cas = new ContentAddressableStorage(t.context.basePath); -}); - -test.after.always(async () => { - await rimraf(TEST_DIR); -}); - -test("contentPath: Computes deterministic path from integrity", (t) => { - const cas = t.context.cas; - - // Use a known integrity hash - const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; - const result = cas.contentPath(integrity); - - t.true(result.startsWith(t.context.basePath)); - t.true(result.includes("sha256")); - - // Verify determinism: same input produces same output - t.is(result, cas.contentPath(integrity)); -}); - -test("contentPath: Path contains algorithm and hex digest segments", (t) => { - const cas = t.context.cas; - const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; - const result = cas.contentPath(integrity); - - // Path structure: {basePath}/sha256/{xx}/{yy}/{rest} - const relPath = path.relative(t.context.basePath, result); - const parts = relPath.split(path.sep); - - t.is(parts[0], "sha256"); - t.is(parts[1].length, 2); // First 2 hex chars - t.is(parts[2].length, 2); // Next 2 hex chars - t.true(parts[3].length > 0); // Remaining hex chars -}); - -test("contentPath: Different integrities produce different paths", (t) => { - const cas = t.context.cas; - const integrity1 = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; - const integrity2 = "sha256-LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564="; - - t.not(cas.contentPath(integrity1), cas.contentPath(integrity2)); -}); - -test("has: Returns false when content does not exist", async (t) => { - const cas = t.context.cas; - const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; - - t.false(await cas.has(integrity)); -}); - -test("has: Returns true after content is stored", async (t) => { - const cas = t.context.cas; - const content = Buffer.from("test content"); - const integrity = "sha256-6DvEUQhlMraqbfGBGR2fhsJaNhr0K0VhMupLPEYrmIY="; - - // Compute real integrity for test content - const ssri = await import("ssri"); - const realIntegrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); - - await cas.put(realIntegrity, content); - t.true(await cas.has(realIntegrity)); -}); - -test("put: Stores gzip-compressed content at correct path", async (t) => { - const cas = t.context.cas; - const content = Buffer.from("hello world"); - - const ssri = await import("ssri"); - const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); - - await cas.put(integrity, content); - - // Verify file exists at computed path - const contentPath = cas.contentPath(integrity); - const compressedData = await readFile(contentPath); - - // Verify it's gzip-compressed: decompress and compare - const decompressed = await promisify(gunzip)(compressedData); - t.deepEqual(decompressed, content); -}); - -test("put: Deduplicates — skips write if content already exists", async (t) => { - const cas = t.context.cas; - const content = Buffer.from("deduplicated content"); - - const ssri = await import("ssri"); - const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); - - await cas.put(integrity, content); - const contentPath = cas.contentPath(integrity); - const stat1 = fs.statSync(contentPath); - - // Small delay to ensure mtime would differ if rewritten - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Write same content again - await cas.put(integrity, content); - const stat2 = fs.statSync(contentPath); - - // File should not have been rewritten (mtime unchanged) - t.is(stat1.mtimeMs, stat2.mtimeMs); -}); - -test("createReadStream: Returns decompressed content stream", async (t) => { - const cas = t.context.cas; - const content = Buffer.from("stream test content"); - - const ssri = await import("ssri"); - const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); - - await cas.put(integrity, content); - - const stream = cas.createReadStream(integrity); - const chunks = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - const result = Buffer.concat(chunks); - t.deepEqual(result, content); -}); - -test("readContent: Returns decompressed buffer", async (t) => { - const cas = t.context.cas; - const content = Buffer.from("buffer test content"); - - const ssri = await import("ssri"); - const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); - - await cas.put(integrity, content); - - const result = await cas.readContent(integrity); - t.deepEqual(result, content); -}); - -test("readContent: Throws when content does not exist", async (t) => { - const cas = t.context.cas; - const integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; - - await t.throwsAsync(cas.readContent(integrity), {code: "ENOENT"}); -}); - -test("put + readContent: Roundtrip with large content", async (t) => { - const cas = t.context.cas; - // Create a 1MB buffer with random-ish content - const content = Buffer.alloc(1024 * 1024); - for (let i = 0; i < content.length; i++) { - content[i] = i % 256; - } - - const ssri = await import("ssri"); - const integrity = ssri.fromData(content, {algorithms: ["sha256"]}).toString(); - - await cas.put(integrity, content); - const result = await cas.readContent(integrity); - t.deepEqual(result, content); -}); diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index 4d489a5c2f4..74bbc2e7331 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -66,8 +66,17 @@ function createMockCacheManager() { readTaskMetadata: sinon.stub().resolves(null), writeTaskMetadata: sinon.stub().resolves(), writeStageResource: sinon.stub().resolves(), - getResourcePathForStage: sinon.stub().resolves("/fake/cache/path"), - contentPath: sinon.stub().returns("/fake/cas/content/path") + hasContent: sinon.stub().returns(false), + readContent: sinon.stub().returns(Buffer.from("test")), + readContentRaw: sinon.stub().returns(Buffer.from("test")), + putContent: sinon.stub(), + hasResourceForStage: sinon.stub().returns(false), + beginContentBatch: sinon.stub(), + endContentBatch: sinon.stub(), + rollbackContentBatch: sinon.stub(), + beginMetadataBatch: sinon.stub(), + endMetadataBatch: sinon.stub(), + rollbackMetadataBatch: sinon.stub(), }; } @@ -969,13 +978,13 @@ test("freezeUntransformedSources: writes only untransformed source files to CAS" await cache.allTasksCompleted(); - // writeStageResource should be called for untransformed files /c.js and /d.js - const stageResourceCalls = cacheManager.writeStageResource.getCalls(); - const writtenPaths = stageResourceCalls.map((call) => call.args[0].getOriginalPath()); - t.true(writtenPaths.includes("/c.js"), "Untransformed /c.js written to CAS"); - t.true(writtenPaths.includes("/d.js"), "Untransformed /d.js written to CAS"); - t.false(writtenPaths.includes("/a.js"), "Transformed /a.js NOT written to CAS by freeze"); - t.false(writtenPaths.includes("/b.js"), "Transformed /b.js NOT written to CAS by freeze"); + // putContent should be called for untransformed files /c.js and /d.js + const putContentCalls = cacheManager.putContent.getCalls(); + const writtenIntegrities = putContentCalls.map((call) => call.args[0]); + t.true(writtenIntegrities.includes("hash-c"), "Untransformed /c.js written to CAS"); + t.true(writtenIntegrities.includes("hash-d"), "Untransformed /d.js written to CAS"); + t.false(writtenIntegrities.includes("hash-a"), "Transformed /a.js NOT written to CAS by freeze"); + t.false(writtenIntegrities.includes("hash-b"), "Transformed /b.js NOT written to CAS by freeze"); }); test("freezeUntransformedSources: early return when all sources overlayed", async (t) => { @@ -1249,11 +1258,11 @@ test("freezeUntransformedSources: delta path — only reads new files missing fr await cache.allTasksCompleted(); - // writeStageResource should be called only for /e.js (the new file) - const stageResourceCalls = cacheManager.writeStageResource.getCalls(); - const writtenPaths = stageResourceCalls.map((call) => call.args[0].getOriginalPath()); - t.is(writtenPaths.length, 1, "Only 1 CAS write for the new file"); - t.true(writtenPaths.includes("/e.js"), "New file /e.js written to CAS"); + // putContent should be called only for /e.js (the new file) + const putContentCalls = cacheManager.putContent.getCalls(); + const writtenIntegrities = putContentCalls.map((call) => call.args[0]); + t.is(writtenIntegrities.length, 1, "Only 1 CAS write for the new file"); + t.true(writtenIntegrities.includes("hash-e"), "New file /e.js written to CAS"); // Merged metadata should contain all 3 untransformed files const sourceStageCalls = cacheManager.writeStageCache.getCalls().filter( From 0c087f8f4f0881a59a6ac10854a01c062404b7d0 Mon Sep 17 00:00:00 2001 From: Max Reichmann Date: Mon, 13 Apr 2026 17:31:26 +0200 Subject: [PATCH 223/223] feat: Introduce --cache option for ui5 build & ui5 serve --- .../documentation/docs/updates/migrate-v5.md | 8 + package-lock.json | 1961 ++++++++--------- packages/cli/lib/cli/commands/build.js | 35 +- packages/cli/lib/cli/commands/serve.js | 40 +- packages/cli/lib/cli/commands/tree.js | 12 +- packages/cli/test/lib/cli/commands/build.js | 24 +- packages/cli/test/lib/cli/commands/serve.js | 44 +- packages/cli/test/lib/cli/commands/tree.js | 32 +- packages/project/lib/build/BuildServer.js | 5 +- packages/project/lib/build/ProjectBuilder.js | 2 + packages/project/lib/build/cache/Cache.js | 18 + .../lib/build/cache/ProjectBuildCache.js | 79 +- .../project/lib/build/helpers/BuildContext.js | 7 +- .../lib/build/helpers/ProjectBuildContext.js | 3 +- packages/project/lib/graph/ProjectGraph.js | 7 + packages/project/lib/graph/graph.js | 24 +- .../project/lib/graph/helpers/ui5Framework.js | 8 +- .../Sapui5MavenSnapshotResolver.js | 8 +- .../lib/ui5Framework/maven/Installer.js | 23 +- .../lib/ui5Framework/maven/Registry.js | 8 +- .../maven/{CacheMode.js => SnapshotCache.js} | 4 +- packages/project/package.json | 3 +- .../library.d/main/src/library/d/.library | 11 + .../library.d/main/src/library/d/data.json | 1 + .../library.d/main/src/library/d/some.js | 4 + .../library.d/main/test/library/d/Test.html | 0 .../node_modules/library.d/package.json | 9 + .../node_modules/library.d/ui5.yaml | 10 + .../fixtures/application.i.copy/package.json | 12 + .../test/fixtures/application.i.copy/ui5.yaml | 5 + .../application.i.copy/webapp/index.html | 9 + .../application.i.copy/webapp/manifest.json | 13 + .../application.i.copy/webapp/test.js | 5 + .../library.d/main/src/library/d/.library | 11 + .../library.d/main/src/library/d/data.json | 1 + .../library.d/main/src/library/d/some.js | 4 + .../library.d/main/test/library/d/Test.html | 0 .../node_modules/library.d/package.json | 9 + .../node_modules/library.d/ui5.yaml | 10 + .../test/fixtures/application.i/package.json | 12 + .../test/fixtures/application.i/ui5.yaml | 5 + .../fixtures/application.i/webapp/index.html | 9 + .../application.i/webapp/manifest.json | 13 + .../fixtures/application.i/webapp/test.js | 5 + .../test/lib/build/BuildServer.integration.js | 269 ++- .../lib/build/ProjectBuilder.integration.js | 271 ++- .../test/lib/graph/graph.integration.js | 6 +- packages/project/test/lib/graph/graph.js | 22 +- .../test/lib/graph/helpers/ui5Framework.js | 26 +- packages/project/test/lib/package-exports.js | 2 +- .../test/lib/ui5framework/maven/Installer.js | 6 +- packages/server/lib/server.js | 6 +- 52 files changed, 1969 insertions(+), 1152 deletions(-) create mode 100644 packages/project/lib/build/cache/Cache.js rename packages/project/lib/ui5Framework/maven/{CacheMode.js => SnapshotCache.js} (77%) create mode 100644 packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/.library create mode 100644 packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/data.json create mode 100644 packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/some.js create mode 100644 packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/test/library/d/Test.html create mode 100644 packages/project/test/fixtures/application.i.copy/node_modules/library.d/package.json create mode 100644 packages/project/test/fixtures/application.i.copy/node_modules/library.d/ui5.yaml create mode 100644 packages/project/test/fixtures/application.i.copy/package.json create mode 100644 packages/project/test/fixtures/application.i.copy/ui5.yaml create mode 100644 packages/project/test/fixtures/application.i.copy/webapp/index.html create mode 100644 packages/project/test/fixtures/application.i.copy/webapp/manifest.json create mode 100644 packages/project/test/fixtures/application.i.copy/webapp/test.js create mode 100644 packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/.library create mode 100644 packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/data.json create mode 100644 packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/some.js create mode 100644 packages/project/test/fixtures/application.i/node_modules/library.d/main/test/library/d/Test.html create mode 100644 packages/project/test/fixtures/application.i/node_modules/library.d/package.json create mode 100644 packages/project/test/fixtures/application.i/node_modules/library.d/ui5.yaml create mode 100644 packages/project/test/fixtures/application.i/package.json create mode 100644 packages/project/test/fixtures/application.i/ui5.yaml create mode 100644 packages/project/test/fixtures/application.i/webapp/index.html create mode 100644 packages/project/test/fixtures/application.i/webapp/manifest.json create mode 100644 packages/project/test/fixtures/application.i/webapp/test.js diff --git a/internal/documentation/docs/updates/migrate-v5.md b/internal/documentation/docs/updates/migrate-v5.md index b4de575af10..e5f9fe91748 100644 --- a/internal/documentation/docs/updates/migrate-v5.md +++ b/internal/documentation/docs/updates/migrate-v5.md @@ -15,6 +15,8 @@ Or update your global install via: `npm i --global @ui5/cli@next` - **@ui5/cli: `ui5 init` defaults to Specification Version 5.0** +- **Rename: Command Option `--cache-mode` is now `--snapshot-cache`** + ## Node.js and npm Version Support @@ -27,6 +29,12 @@ UI5 CLI 5.x introduces **Specification Version 5.0**, which enables the new Comp Projects using older **Specification Versions** are expected to be **fully compatible with UI5 CLI v5**. +## Rename of Command Option + +With Specification Version 5.0, the option `--cache-mode` (for commands `ui5 build` and `ui5 serve`) has been renamed to `--snapshot-cache`. + +The behavior remains the same. When `--cache-mode` is used, a deprecation warning is logged and `--snapshot-cache` is set to `Default`. + ## UI5 CLI Init Command The `ui5 init` command now generates projects with Specification Version 5.0 by default. diff --git a/package-lock.json b/package-lock.json index 00f2bcf5d52..9b356124cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,17 +148,17 @@ "node": "20 || >=22" } }, - "internal/shrinkwrap-extractor/node_modules/nopt": { - "version": "9.0.0", - "license": "ISC", + "internal/shrinkwrap-extractor/node_modules/minimatch": { + "version": "10.2.5", + "license": "BlueOak-1.0.0", "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@adobe/css-tools": { @@ -166,13 +166,13 @@ "license": "MIT" }, "node_modules/@algolia/abtesting": { - "version": "1.16.1", + "version": "1.16.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" @@ -216,154 +216,154 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/ingestion": { - "version": "1.50.1", + "version": "1.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.50.1", + "version": "1.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1" + "@algolia/client-common": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1" + "@algolia/client-common": "5.50.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1" + "@algolia/client-common": "5.50.2" }, "engines": { "node": ">= 14.0.0" @@ -1028,7 +1028,7 @@ } }, "node_modules/@conventional-changelog/git-client": { - "version": "2.6.0", + "version": "2.7.0", "dev": true, "license": "MIT", "dependencies": { @@ -1041,7 +1041,7 @@ }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.3.0" + "conventional-commits-parser": "^6.4.0" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -1095,9 +1095,7 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", "dev": true, "license": "MIT", "optional": true, @@ -1108,9 +1106,7 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", "dev": true, "license": "MIT", "optional": true, @@ -1121,8 +1117,6 @@ }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1222,31 +1216,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "dev": true, @@ -1306,20 +1275,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "dev": true, @@ -1336,17 +1291,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { "version": "9.39.4", "dev": true, @@ -1430,7 +1374,7 @@ } }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.76", + "version": "1.2.78", "license": "CC0-1.0", "dependencies": { "@iconify/types": "*" @@ -1474,6 +1418,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "license": "ISC", @@ -1528,6 +1487,18 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { "version": "3.14.2", "dev": true, @@ -1540,8 +1511,44 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", + "version": "0.1.6", "dev": true, "license": "MIT", "engines": { @@ -1592,10 +1599,10 @@ } }, "node_modules/@jsdoc/salty": { - "version": "0.2.11", + "version": "0.2.12", "license": "Apache-2.0", "dependencies": { - "lodash": "^4.17.23" + "lodash": "^4.18.1" }, "engines": { "node": ">=v12.0.0" @@ -1621,10 +1628,30 @@ "node": ">=18" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.4", "dev": true, "license": "MIT", "optional": true, @@ -1695,7 +1722,7 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.2.7", + "version": "11.3.5", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2170,7 +2197,7 @@ } }, "node_modules/@npmcli/arborist/node_modules/brace-expansion": { - "version": "2.0.3", + "version": "2.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -2212,6 +2239,25 @@ "dev": true, "license": "ISC" }, + "node_modules/@npmcli/arborist/node_modules/glob": { + "version": "10.5.0", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@npmcli/arborist/node_modules/hosted-git-info": { "version": "7.0.2", "dev": true, @@ -2970,6 +3016,21 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@npmcli/arborist/node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@npmcli/arborist/node_modules/postcss-selector-parser": { "version": "6.1.2", "dev": true, @@ -3263,19 +3324,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/config/node_modules/nopt": { - "version": "9.0.0", - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@npmcli/fs": { "version": "5.0.0", "license": "ISC", @@ -3304,7 +3352,7 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.2.7", + "version": "11.3.5", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -3337,34 +3385,11 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/map-workspaces/node_modules/glob": { - "version": "13.0.6", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/map-workspaces/node_modules/lru-cache": { - "version": "11.2.7", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@npmcli/map-workspaces/node_modules/path-scurry": { - "version": "2.0.2", + "node_modules/@npmcli/map-workspaces/node_modules/minimatch": { + "version": "10.2.5", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -3417,42 +3442,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "13.0.6", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/package-json/node_modules/lru-cache": { - "version": "11.2.7", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@npmcli/package-json/node_modules/path-scurry": { - "version": "2.0.2", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@npmcli/promise-spawn": { "version": "9.0.1", "license": "ISC", @@ -3603,9 +3592,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3620,9 +3606,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3637,9 +3620,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3654,9 +3634,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3671,9 +3648,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3688,9 +3662,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3705,9 +3676,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3722,9 +3690,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3971,7 +3936,7 @@ } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", + "version": "0.5.1", "license": "Apache-2.0", "engines": { "node": "^18.17.0 || >=20.5.0" @@ -4070,7 +4035,7 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.0", + "version": "15.3.2", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4078,7 +4043,7 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "9.0.3", + "version": "10.0.2", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4177,10 +4142,21 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@tufjs/models/node_modules/minimatch": { + "version": "10.2.5", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -4228,10 +4204,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", + "version": "25.6.0", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -4247,7 +4223,7 @@ "license": "MIT" }, "node_modules/@typescript-eslint/types": { - "version": "8.58.0", + "version": "8.58.2", "dev": true, "license": "MIT", "engines": { @@ -4323,6 +4299,72 @@ "node": ">=18" } }, + "node_modules/@vercel/nft/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/nft/node_modules/brace-expansion": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vercel/nft/node_modules/glob": { + "version": "10.5.0", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vercel/nft/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/@vercel/nft/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vercel/nft/node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "license": "MIT", @@ -4668,23 +4710,23 @@ } }, "node_modules/algoliasearch": { - "version": "5.50.1", + "version": "5.50.2", "license": "MIT", "dependencies": { - "@algolia/abtesting": "1.16.1", - "@algolia/client-abtesting": "5.50.1", - "@algolia/client-analytics": "5.50.1", - "@algolia/client-common": "5.50.1", - "@algolia/client-insights": "5.50.1", - "@algolia/client-personalization": "5.50.1", - "@algolia/client-query-suggestions": "5.50.1", - "@algolia/client-search": "5.50.1", - "@algolia/ingestion": "1.50.1", - "@algolia/monitoring": "1.50.1", - "@algolia/recommend": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/abtesting": "1.16.2", + "@algolia/client-abtesting": "5.50.2", + "@algolia/client-analytics": "5.50.2", + "@algolia/client-common": "5.50.2", + "@algolia/client-insights": "5.50.2", + "@algolia/client-personalization": "5.50.2", + "@algolia/client-query-suggestions": "5.50.2", + "@algolia/client-search": "5.50.2", + "@algolia/ingestion": "1.50.2", + "@algolia/monitoring": "1.50.2", + "@algolia/recommend": "5.50.2", + "@algolia/requester-browser-xhr": "5.50.2", + "@algolia/requester-fetch": "5.50.2", + "@algolia/requester-node-http": "5.50.2" }, "engines": { "node": ">= 14.0.0" @@ -4697,46 +4739,6 @@ "string-width": "^4.1.0" } }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "license": "MIT", @@ -4923,7 +4925,7 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.27", + "version": "10.5.0", "funding": [ { "type": "opencollective", @@ -4940,8 +4942,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -5017,6 +5019,17 @@ } } }, + "node_modules/ava/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "dev": true, @@ -5073,7 +5086,7 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", + "version": "2.10.19", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5196,29 +5209,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/boxen/node_modules/type-fest": { - "version": "4.41.0", - "license": "(MIT OR CC0-1.0)", + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", "engines": { - "node": ">=16" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/boxen/node_modules/wrap-ansi": { - "version": "9.0.2", + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "4.41.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/brace-expansion": { @@ -5338,42 +5365,13 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/cacache/node_modules/glob": { - "version": "13.0.6", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.2.7", + "version": "11.3.5", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/cacache/node_modules/path-scurry": { - "version": "2.0.2", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/caching-transform": { "version": "4.0.0", "dev": true, @@ -5427,13 +5425,13 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", + "version": "1.0.9", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -5498,7 +5496,7 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", + "version": "1.0.30001788", "funding": [ { "type": "opencollective", @@ -5545,15 +5543,34 @@ } }, "node_modules/chalk": { - "version": "5.6.2", + "version": "4.1.2", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "license": "MIT", @@ -5572,8 +5589,6 @@ }, "node_modules/check-engine-light": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/check-engine-light/-/check-engine-light-0.4.0.tgz", - "integrity": "sha512-hZzy5cbJg52nDGgyiNDVpjzrIo6V49lpFVJ7hJiGpbWs9in5mtpRMqdM1VPptab2QTkwYdac98PaXSBmQqh1Tg==", "dev": true, "license": "ISC", "dependencies": { @@ -5718,53 +5733,34 @@ "node": ">=4" } }, - "node_modules/cli-progress/node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-progress/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, - "node_modules/cli-progress/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-progress/node_modules/string-width": { - "version": "4.2.3", + "node_modules/cli-truncate": { + "version": "4.0.0", + "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cli-progress/node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=18" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=18" @@ -5808,32 +5804,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/strip-ansi": { "version": "6.0.1", "dev": true, @@ -6248,11 +6218,11 @@ } }, "node_modules/cosmiconfig-typescript-loader": { - "version": "6.2.0", + "version": "6.3.0", "dev": true, "license": "MIT", "dependencies": { - "jiti": "^2.6.1" + "jiti": "2.6.1" }, "engines": { "node": ">=v18" @@ -6334,7 +6304,7 @@ } }, "node_modules/css-declaration-sorter": { - "version": "7.3.1", + "version": "7.4.0", "license": "ISC", "engines": { "node": "^14 || ^16 || >=18" @@ -6389,10 +6359,10 @@ } }, "node_modules/cssnano": { - "version": "7.1.4", + "version": "7.1.5", "license": "MIT", "dependencies": { - "cssnano-preset-default": "^7.0.12", + "cssnano-preset-default": "^7.0.13", "lilconfig": "^3.1.3" }, "engines": { @@ -6407,24 +6377,24 @@ } }, "node_modules/cssnano-preset-default": { - "version": "7.0.12", + "version": "7.0.13", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "css-declaration-sorter": "^7.2.0", "cssnano-utils": "^5.0.1", "postcss-calc": "^10.1.1", - "postcss-colormin": "^7.0.7", - "postcss-convert-values": "^7.0.9", + "postcss-colormin": "^7.0.8", + "postcss-convert-values": "^7.0.10", "postcss-discard-comments": "^7.0.6", "postcss-discard-duplicates": "^7.0.2", "postcss-discard-empty": "^7.0.1", "postcss-discard-overridden": "^7.0.1", "postcss-merge-longhand": "^7.0.5", - "postcss-merge-rules": "^7.0.8", + "postcss-merge-rules": "^7.0.9", "postcss-minify-font-values": "^7.0.1", - "postcss-minify-gradients": "^7.0.2", - "postcss-minify-params": "^7.0.6", + "postcss-minify-gradients": "^7.0.3", + "postcss-minify-params": "^7.0.7", "postcss-minify-selectors": "^7.0.6", "postcss-normalize-charset": "^7.0.1", "postcss-normalize-display-values": "^7.0.1", @@ -6432,11 +6402,11 @@ "postcss-normalize-repeat-style": "^7.0.1", "postcss-normalize-string": "^7.0.1", "postcss-normalize-timing-functions": "^7.0.1", - "postcss-normalize-unicode": "^7.0.6", + "postcss-normalize-unicode": "^7.0.7", "postcss-normalize-url": "^7.0.1", "postcss-normalize-whitespace": "^7.0.1", "postcss-ordered-values": "^7.0.2", - "postcss-reduce-initial": "^7.0.6", + "postcss-reduce-initial": "^7.0.7", "postcss-reduce-transforms": "^7.0.1", "postcss-svgo": "^7.1.1", "postcss-unique-selectors": "^7.0.5" @@ -6735,6 +6705,66 @@ "node": "^14.13.1 || >=16.0.0" } }, + "node_modules/devcert-sanscache/node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/devcert-sanscache/node_modules/brace-expansion": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/devcert-sanscache/node_modules/glob": { + "version": "10.5.0", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/devcert-sanscache/node_modules/lru-cache": { + "version": "10.4.3", + "license": "ISC" + }, + "node_modules/devcert-sanscache/node_modules/minimatch": { + "version": "9.0.9", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/devcert-sanscache/node_modules/path-scurry": { + "version": "1.11.1", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/devcert-sanscache/node_modules/rimraf": { "version": "5.0.10", "license": "ISC", @@ -6880,7 +6910,7 @@ "license": "MIT" }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.0.3", + "version": "2.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -6906,7 +6936,7 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.331", + "version": "1.5.339", "license": "ISC" }, "node_modules/emittery": { @@ -6921,7 +6951,7 @@ } }, "node_modules/emoji-regex": { - "version": "10.6.0", + "version": "8.0.0", "license": "MIT" }, "node_modules/emoji-regex-xs": { @@ -7030,7 +7060,7 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", + "version": "1.24.2", "dev": true, "license": "MIT", "dependencies": { @@ -7213,10 +7243,11 @@ "license": "MIT" }, "node_modules/escape-string-regexp": { - "version": "5.0.0", + "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7398,17 +7429,6 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { "version": "5.0.1", "dev": true, @@ -7501,133 +7521,11 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/esmock": { "version": "2.7.3", "dev": true, @@ -8058,43 +7956,91 @@ "version": "2.6.9", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "license": "MIT" - }, - "node_modules/find-cache-dir": { - "version": "3.3.2", + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", "dev": true, "license": "MIT", "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" + "semver": "^6.0.0" }, "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-cache-dir/node_modules/make-dir": { - "version": "3.1.0", + "node_modules/find-cache-dir/node_modules/p-limit": { + "version": "2.3.0", "dev": true, "license": "MIT", "dependencies": { - "semver": "^6.0.0" + "p-try": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-cache-dir/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-cache-dir/node_modules/pkg-dir": { "version": "4.2.0", "dev": true, @@ -8115,14 +8061,18 @@ } }, "node_modules/find-up": { - "version": "4.1.0", + "version": "5.0.0", + "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", + "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/find-up-simple": { @@ -8478,18 +8428,15 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "license": "ISC", + "version": "13.0.6", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8506,25 +8453,14 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "license": "ISC", + "version": "10.2.5", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8551,7 +8487,7 @@ } }, "node_modules/globals": { - "version": "17.4.0", + "version": "17.5.0", "dev": true, "license": "MIT", "engines": { @@ -8800,7 +8736,7 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.2.7", + "version": "11.3.5", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9014,6 +8950,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "10.2.5", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "dev": true, @@ -9054,6 +9003,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-local/node_modules/find-up": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/locate-path": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/p-limit": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local/node_modules/p-locate": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/import-local/node_modules/pkg-dir": { "version": "4.2.0", "license": "MIT", @@ -9796,20 +9789,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-processinfo/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/istanbul-lib-processinfo/node_modules/glob": { "version": "7.2.3", "dev": true, @@ -9829,17 +9808,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/istanbul-lib-processinfo/node_modules/minimatch": { - "version": "3.1.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/istanbul-lib-processinfo/node_modules/p-map": { "version": "3.0.0", "dev": true, @@ -9951,6 +9919,57 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/js-beautify/node_modules/nopt": { "version": "7.2.1", "dev": true, @@ -9965,6 +9984,21 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/js-cookie": { "version": "3.0.5", "dev": true, @@ -10336,13 +10370,17 @@ } }, "node_modules/locate-path": { - "version": "5.0.0", + "version": "6.0.0", + "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lockfile": { @@ -10520,6 +10558,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/matcher/node_modules/escape-string-regexp": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -10773,16 +10822,28 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "10.2.5", - "license": "BlueOak-1.0.0", + "version": "3.1.5", + "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" + } + }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.14", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/minimist": { @@ -10998,19 +11059,6 @@ "node-gyp-build-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/nopt": { - "version": "9.0.0", - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/node-preload": { "version": "0.2.1", "dev": true, @@ -11046,25 +11094,16 @@ } }, "node_modules/nopt": { - "version": "8.1.0", - "dev": true, + "version": "9.0.0", "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nopt/node_modules/abbrev": { - "version": "3.0.1", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-package-data": { @@ -11270,20 +11309,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/nyc/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/nyc/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/nyc/node_modules/cliui": { "version": "6.0.0", "dev": true, @@ -11299,10 +11324,17 @@ "dev": true, "license": "MIT" }, - "node_modules/nyc/node_modules/emoji-regex": { - "version": "8.0.0", + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/nyc/node_modules/glob": { "version": "7.2.3", @@ -11323,14 +11355,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/nyc/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/nyc/node_modules/istanbul-lib-instrument": { "version": "6.0.3", "dev": true, @@ -11346,6 +11370,17 @@ "node": ">=10" } }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nyc/node_modules/make-dir": { "version": "3.1.0", "dev": true, @@ -11368,15 +11403,29 @@ "semver": "bin/semver.js" } }, - "node_modules/nyc/node_modules/minimatch": { - "version": "3.1.5", + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "p-try": "^2.0.0" }, "engines": { - "node": "*" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/nyc/node_modules/p-map": { @@ -11409,19 +11458,6 @@ "dev": true, "license": "ISC" }, - "node_modules/nyc/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nyc/node_modules/strip-ansi": { "version": "6.0.1", "dev": true, @@ -11682,26 +11718,31 @@ } }, "node_modules/p-limit": { - "version": "2.3.0", + "version": "3.1.0", + "dev": true, "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "4.1.0", + "version": "5.0.0", + "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-map": { @@ -11951,22 +11992,25 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", + "version": "2.0.2", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "license": "ISC" + "version": "11.3.5", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { "version": "0.1.13", @@ -12023,63 +12067,6 @@ "node": ">=10" } }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/plur": { "version": "5.1.0", "dev": true, @@ -12115,7 +12102,7 @@ } }, "node_modules/postcss": { - "version": "8.5.8", + "version": "8.5.10", "funding": [ { "type": "opencollective", @@ -12155,11 +12142,11 @@ } }, "node_modules/postcss-colormin": { - "version": "7.0.7", + "version": "7.0.8", "license": "MIT", "dependencies": { - "@colordx/core": "^5.0.0", - "browserslist": "^4.28.1", + "@colordx/core": "^5.0.3", + "browserslist": "^4.28.2", "caniuse-api": "^3.0.0", "postcss-value-parser": "^4.2.0" }, @@ -12171,10 +12158,10 @@ } }, "node_modules/postcss-convert-values": { - "version": "7.0.9", + "version": "7.0.10", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -12242,10 +12229,10 @@ } }, "node_modules/postcss-merge-rules": { - "version": "7.0.8", + "version": "7.0.9", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "caniuse-api": "^3.0.0", "cssnano-utils": "^5.0.1", "postcss-selector-parser": "^7.1.1" @@ -12271,10 +12258,10 @@ } }, "node_modules/postcss-minify-gradients": { - "version": "7.0.2", + "version": "7.0.3", "license": "MIT", "dependencies": { - "@colordx/core": "^5.0.0", + "@colordx/core": "^5.0.3", "cssnano-utils": "^5.0.1", "postcss-value-parser": "^4.2.0" }, @@ -12286,10 +12273,10 @@ } }, "node_modules/postcss-minify-params": { - "version": "7.0.6", + "version": "7.0.7", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "cssnano-utils": "^5.0.1", "postcss-value-parser": "^4.2.0" }, @@ -12390,10 +12377,10 @@ } }, "node_modules/postcss-normalize-unicode": { - "version": "7.0.6", + "version": "7.0.7", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -12444,10 +12431,10 @@ } }, "node_modules/postcss-reduce-initial": { - "version": "7.0.6", + "version": "7.0.7", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "caniuse-api": "^3.0.0" }, "engines": { @@ -12513,7 +12500,7 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.29.0", + "version": "10.29.1", "license": "MIT", "funding": { "type": "opencollective", @@ -12673,7 +12660,7 @@ } }, "node_modules/qs": { - "version": "6.15.0", + "version": "6.15.1", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -13078,9 +13065,10 @@ } }, "node_modules/resolve": { - "version": "1.22.11", + "version": "1.22.12", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -13150,45 +13138,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "13.0.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.2.7", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "2.0.2", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.60.1", "license": "MIT", @@ -13563,11 +13512,11 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -13635,15 +13584,14 @@ } }, "node_modules/sinon": { - "version": "21.0.3", + "version": "21.1.2", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.1", - "@sinonjs/samsam": "^9.0.3", - "diff": "^8.0.3", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.3.2", + "@sinonjs/samsam": "^10.0.2", + "diff": "^8.0.4" }, "funding": { "type": "opencollective", @@ -13755,27 +13703,13 @@ "dependencies": { "foreground-child": "^2.0.0", "is-windows": "^1.0.2", - "make-dir": "^3.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "which": "^2.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/spawn-wrap/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/spawn-wrap/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/spawn-wrap/node_modules/foreground-child": { @@ -13828,17 +13762,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/spawn-wrap/node_modules/minimatch": { - "version": "3.1.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/spawn-wrap/node_modules/rimraf": { "version": "3.0.2", "dev": true, @@ -14019,8 +13942,6 @@ }, "node_modules/ssri": { "version": "13.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", - "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -14075,18 +13996,15 @@ } }, "node_modules/string-width": { - "version": "7.2.0", + "version": "4.2.3", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -14109,10 +14027,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "license": "MIT", @@ -14130,6 +14044,30 @@ "node": ">=8" } }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "dev": true, @@ -14303,10 +14241,10 @@ "license": "MIT" }, "node_modules/stylehacks": { - "version": "7.0.8", + "version": "7.0.9", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "postcss-selector-parser": "^7.1.1" }, "engines": { @@ -14592,20 +14530,6 @@ "node": ">=8" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "dev": true, @@ -14625,17 +14549,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/testdouble": { "version": "3.20.2", "dev": true, @@ -14664,7 +14577,7 @@ } }, "node_modules/tinyexec": { - "version": "1.0.4", + "version": "1.1.1", "dev": true, "license": "MIT", "engines": { @@ -14672,11 +14585,11 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.16", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -14996,14 +14909,14 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.24.1", + "version": "6.25.0", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.18.2", + "version": "7.19.2", "license": "MIT" }, "node_modules/unicorn-magic": { @@ -15167,6 +15080,16 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/uri-js": { "version": "4.4.1", "dev": true, @@ -15547,6 +15470,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -15565,15 +15507,15 @@ "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "8.1.0", + "version": "9.0.2", "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -15615,29 +15557,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { "version": "6.0.1", "license": "MIT", @@ -15649,19 +15568,19 @@ } }, "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", + "version": "10.6.0", "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", + "version": "7.2.0", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -15784,51 +15703,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yesno": { "version": "0.4.0", "license": "BSD" @@ -15951,6 +15825,16 @@ "npm": ">= 8" } }, + "packages/cli/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "packages/cli/node_modules/cliui": { "version": "9.0.1", "license": "ISC", @@ -15963,19 +15847,23 @@ "node": ">=20" } }, - "packages/cli/node_modules/wrap-ansi": { - "version": "9.0.2", + "packages/cli/node_modules/emoji-regex": { + "version": "10.6.0", + "license": "MIT" + }, + "packages/cli/node_modules/string-width": { + "version": "7.2.0", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "packages/cli/node_modules/yargs": { @@ -16032,6 +15920,16 @@ "npm": ">= 8" } }, + "packages/fs/node_modules/escape-string-regexp": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/fs/node_modules/globby": { "version": "15.0.0", "license": "MIT", @@ -16057,6 +15955,19 @@ "node": ">= 4" } }, + "packages/fs/node_modules/minimatch": { + "version": "10.2.5", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/logger": { "name": "@ui5/logger", "version": "5.0.0-alpha.4", @@ -16081,6 +15992,16 @@ "npm": ">= 8" } }, + "packages/logger/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "packages/project": { "name": "@ui5/project", "version": "5.0.0-alpha.4", @@ -16139,6 +16060,26 @@ } } }, + "packages/project/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/project/node_modules/escape-string-regexp": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/project/node_modules/istanbul-lib-instrument": { "version": "6.0.3", "dev": true, diff --git a/packages/cli/lib/cli/commands/build.js b/packages/cli/lib/cli/commands/build.js index 31e7b56062c..788b5069581 100644 --- a/packages/cli/lib/cli/commands/build.js +++ b/packages/cli/lib/cli/commands/build.js @@ -1,5 +1,6 @@ import baseMiddleware from "../middlewares/base.js"; -import path from "node:path"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("cli:commands:build"); const build = { command: "build", @@ -107,13 +108,31 @@ build.builder = function(cli) { type: "string" }) .option("cache-mode", { + describe: + "As of UI5 CLI version 5, renamed to '--snapshot-cache'. " + + "Use '--snapshot-cache' to control this behavior.", + type: "string", + hidden: true, // Hides it from the help output + }) + .option("snapshot-cache", { describe: "Cache mode to use when consuming SNAPSHOT versions of framework dependencies. " + "The 'Default' behavior is to invalidate the cache after 9 hours. 'Force' uses the cache only and " + "does not create any requests. 'Off' invalidates any existing cache and updates from the repository", type: "string", default: "Default", - choices: ["Default", "Force", "Off"] + choices: ["Default", "Force", "Off"], + }) + .option("cache", { + describe: + "Cache mode to use for building UI5 projects. " + + "The 'Default' behavior is to always use the cache if available. 'Force' uses the cache only " + + "(if it's unavailable or invalid, the build fails). 'ReadOnly' does not create or update any " + + "cache but makes use of a cache if available. 'Off' does not use any cache and always triggers " + + "a rebuild of the project", + type: "string", + default: "Default", + choices: ["Default", "Force", "ReadOnly", "Off"], }) .option("experimental-css-variables", { describe: @@ -150,6 +169,12 @@ build.builder = function(cli) { }; async function handleBuild(argv) { + // Log warning for hidden CLI options + if (Object.prototype.hasOwnProperty.call(argv, "cacheMode")) { + log.warn("As of UI5 CLI version 5, '--cache-mode' was renamed to '--snapshot-cache'. " + + "Use '--snapshot-cache' to control this behavior. "+ + "Setting '--snapshot-cache' to 'Default'..."); + } const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); const command = argv._[argv._.length - 1]; @@ -160,13 +185,13 @@ async function handleBuild(argv) { filePath: argv.dependencyDefinition, rootConfigPath: argv.config, versionOverride: argv.frameworkVersion, - cacheMode: argv.cacheMode, + snapshotCache: argv.snapshotCache, }); } else { graph = await graphFromPackageDependencies({ rootConfigPath: argv.config, versionOverride: argv.frameworkVersion, - cacheMode: argv.cacheMode, + snapshotCache: argv.snapshotCache, workspaceConfigPath: argv.workspaceConfig, workspaceName: argv.workspace === false ? null : argv.workspace, }); @@ -174,7 +199,6 @@ async function handleBuild(argv) { const buildSettings = graph.getRoot().getBuilderSettings() || {}; await graph.build({ graph, - cacheDir: path.join(graph.getRoot().getRootPath(), ".ui5-cache"), destPath: argv.dest, cleanDest: argv["clean-dest"], createBuildManifest: argv["create-build-manifest"], @@ -196,6 +220,7 @@ async function handleBuild(argv) { excludedTasks: argv["exclude-task"], cssVariables: argv["experimental-css-variables"], outputStyle: argv["output-style"], + cache: argv["cache"], }); } diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index 218c72ab1d0..c877f0431ac 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -2,6 +2,8 @@ import path from "node:path"; import os from "node:os"; import chalk from "chalk"; import baseMiddleware from "../middlewares/base.js"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("cli:commands:serve"); // Serve const serve = { @@ -68,12 +70,30 @@ serve.builder = function(cli) { }) .option("cache-mode", { describe: - "Cache mode to use when consuming SNAPSHOT versions of framework dependencies. " + - "The 'Default' behavior is to invalidate the cache after 9 hours. 'Force' uses the cache only and " + - "does not create any requests. 'Off' invalidates any existing cache and updates from the repository", + "As of UI5 CLI version 5, renamed to '--snapshot-cache'. " + + "Use '--snapshot-cache' to control this behavior.", + type: "string", + hidden: true, + }) + .option("snapshot-cache", { + describe: + "Cache mode to use when consuming SNAPSHOT versions of framework dependencies. " + + "The 'Default' behavior is to invalidate the cache after 9 hours. 'Force' uses the cache only and " + + "does not create any requests. 'Off' invalidates any existing cache and updates from the repository", + type: "string", + default: "Default", + choices: ["Default", "Force", "Off"], + }) + .option("cache", { + describe: + "Cache mode to use for building UI5 projects. " + + "The 'Default' behavior is to always use the cache if available. 'Force' uses the cache only " + + "(if it's unavailable or invalid, the build fails). 'Read-only' does not create or update any " + + "cache but makes use of a cache if available. 'Off' does not use any cache and always triggers " + + "a rebuild of the project", type: "string", default: "Default", - choices: ["Default", "Force", "Off"] + choices: ["Default", "Force", "ReadOnly", "Off"], }) .example("ui5 serve", "Start a web server for the current project") .example("ui5 serve --h2", "Enable the HTTP/2 protocol for the web server (requires SSL certificate)") @@ -85,6 +105,11 @@ serve.builder = function(cli) { }; serve.handler = async function(argv) { + if (Object.prototype.hasOwnProperty.call(argv, "cacheMode")) { + log.warn("As of UI5 CLI version 5, '--cache-mode' was renamed to '--snapshot-cache'. " + + "Use '--snapshot-cache' to control this behavior. "+ + "Setting '--snapshot-cache' to 'Default'..."); + } const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); const {serve: serverServe} = await import("@ui5/server"); const {getSslCertificate} = await import("@ui5/server/internal/sslUtil"); @@ -95,13 +120,13 @@ serve.handler = async function(argv) { filePath: argv.dependencyDefinition, rootConfigPath: argv.config, versionOverride: argv.frameworkVersion, - cacheMode: argv.cacheMode, + snapshotCache: argv.snapshotCache, }); } else { graph = await graphFromPackageDependencies({ rootConfigPath: argv.config, versionOverride: argv.frameworkVersion, - cacheMode: argv.cacheMode, + snapshotCache: argv.snapshotCache, workspaceConfigPath: argv.workspaceConfig, workspaceName: argv.workspace === false ? null : argv.workspace, }); @@ -137,7 +162,8 @@ serve.handler = async function(argv) { cert: argv.h2 ? argv.cert : undefined, key: argv.h2 ? argv.key : undefined, sendSAPTargetCSP: !!argv.sapCspPolicies, - serveCSPReports: !!argv.serveCspReports + serveCSPReports: !!argv.serveCspReports, + cache: argv.cache, }; if (serverConfig.h2) { diff --git a/packages/cli/lib/cli/commands/tree.js b/packages/cli/lib/cli/commands/tree.js index e683a72b676..fa2b2db1c01 100644 --- a/packages/cli/lib/cli/commands/tree.js +++ b/packages/cli/lib/cli/commands/tree.js @@ -28,7 +28,13 @@ tree.builder = function(cli) { "Takes the same value as the version part of \"ui5 use\"", type: "string" }) - .option("cache-mode", { + .hide("cache-mode", { + describe: + "As of UI5 CLI version 5, renamed to '--snapshot-cache'. " + + "Use '--snapshot-cache' to control this behavior.", + type: "string", + }) + .option("snapshot-cache", { describe: "Cache mode to use when consuming SNAPSHOT versions of framework dependencies. " + "The 'Default' behavior is to invalidate the cache after 9 hours. 'Force' uses the cache only and " + @@ -51,13 +57,13 @@ tree.handler = async function(argv) { graph = await graphFromStaticFile({ filePath: argv.dependencyDefinition, versionOverride: argv.frameworkVersion, - cacheMode: argv.cacheMode, + snapshotCache: argv.snapshotCache, }); } else { graph = await graphFromPackageDependencies({ rootConfigPath: argv.config, versionOverride: argv.frameworkVersion, - cacheMode: argv.cacheMode, + snapshotCache: argv.snapshotCache, workspaceConfigPath: argv.workspaceConfig, workspaceName: argv.workspace === false ? null : argv.workspace, }); diff --git a/packages/cli/test/lib/cli/commands/build.js b/packages/cli/test/lib/cli/commands/build.js index ac36226cbf8..191bf19d239 100644 --- a/packages/cli/test/lib/cli/commands/build.js +++ b/packages/cli/test/lib/cli/commands/build.js @@ -25,6 +25,8 @@ function getDefaultArgv() { "experimentalCssVariables": false, "cache-mode": "Default", "cacheMode": "Default", + "snapshot-cache": "Default", + "snapshotCache": "Default", "output-style": "Default", "$0": "ui5" }; @@ -131,15 +133,15 @@ test.serial("ui5 build --framework-version", async (t) => { versionOverride: "1.99.0", workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromPackageDependencies got called with expected arguments" ); }); -test.serial("ui5 build --cache-mode", async (t) => { +test.serial("ui5 build --snapshot-cache", async (t) => { const {build, argv, graphFromPackageDependenciesStub} = t.context; - argv.cacheMode = "Off"; + argv.snapshotCache = "Off"; await build.handler(argv); @@ -150,7 +152,7 @@ test.serial("ui5 build --cache-mode", async (t) => { versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Off", + snapshotCache: "Off", }, "generateProjectGraph.graphFromPackageDependencies got called with expected arguments" ); }); @@ -169,7 +171,7 @@ test.serial("ui5 build --config", async (t) => { versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromPackageDependencies got called with expected arguments" ); }); @@ -188,7 +190,7 @@ test.serial("ui5 build --workspace", async (t) => { versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: "dolphin", - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromPackageDependencies got called with expected arguments" ); }); @@ -207,7 +209,7 @@ test.serial("ui5 build --no-workspace", async (t) => { versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: null, - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromPackageDependencies got called with expected arguments" ); }); @@ -227,7 +229,7 @@ test.serial("ui5 build --workspace-config", async (t) => { versionOverride: undefined, workspaceConfigPath: fakePath, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromPackageDependencies got called with expected arguments" ); }); @@ -245,7 +247,7 @@ test.serial("ui5 build --dependency-definition", async (t) => { filePath: "dependencies.yaml", rootConfigPath: undefined, versionOverride: undefined, - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromStaticFile got called with expected arguments" ); }); @@ -264,7 +266,7 @@ test.serial("ui5 build --dependency-definition --config", async (t) => { filePath: "dependencies.yaml", rootConfigPath: "ui5-test.yaml", versionOverride: undefined, - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromStaticFile got called with expected arguments" ); }); @@ -284,7 +286,7 @@ test.serial("ui5 build --dependency-definition --config --framework-version", as filePath: "dependencies.yaml", rootConfigPath: "ui5-test.yaml", versionOverride: "1.99.0", - cacheMode: "Default", + snapshotCache: "Default", }, "generateProjectGraph.graphFromStaticFile got called with expected arguments" ); }); diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index 1f20ba09c48..0b23bd0a8d8 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -26,6 +26,8 @@ function getDefaultArgv() { "serveCspReports": false, "cache-mode": "Default", "cacheMode": "Default", + "snapshot-cache": "Default", + "snapshotCache": "Default", "$0": "ui5" }; } @@ -93,7 +95,7 @@ test.serial("ui5 serve: default", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -139,7 +141,7 @@ test.serial("ui5 serve --h2", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -181,7 +183,7 @@ test.serial("ui5 serve --accept-remote-connections", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, ` @@ -225,7 +227,7 @@ test.serial("ui5 serve --open", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -266,7 +268,7 @@ test.serial("ui5 serve --open (opens default url)", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -308,7 +310,7 @@ test.serial("ui5 serve --config", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: fakePath, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -344,7 +346,7 @@ test.serial("ui5 serve --dependency-definition", async (t) => { t.is(graph.graphFromStaticFile.callCount, 1); t.deepEqual(graph.graphFromStaticFile.getCall(0).args, [{ filePath: fakePath, versionOverride: undefined, - cacheMode: "Default", rootConfigPath: undefined + snapshotCache: "Default", rootConfigPath: undefined }]); t.is(t.context.consoleOutput, `Server started @@ -383,7 +385,7 @@ test.serial("ui5 serve --dependency-definition / --config", async (t) => { t.is(graph.graphFromStaticFile.callCount, 1); t.deepEqual(graph.graphFromStaticFile.getCall(0).args, [{ filePath: fakeDependenciesPath, versionOverride: undefined, - cacheMode: "Default", rootConfigPath: fakeConfigPath + snapshotCache: "Default", rootConfigPath: fakeConfigPath }]); t.is(t.context.consoleOutput, `Server started @@ -419,7 +421,7 @@ test.serial("ui5 serve --framework-version", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: "1.234.5", workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -443,10 +445,10 @@ URL: http://localhost:8080 ]); }); -test.serial("ui5 serve --cache-mode", async (t) => { +test.serial("ui5 serve --snapshotCache", async (t) => { const {argv, serve, graph, server, fakeGraph} = t.context; - argv.cacheMode = "Force"; + argv.snapshotCache = "Force"; await serve.handler(argv); @@ -455,7 +457,7 @@ test.serial("ui5 serve --cache-mode", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Force", + snapshotCache: "Force", }]); t.is(t.context.consoleOutput, `Server started @@ -491,7 +493,7 @@ test.serial("ui5 serve --workspace", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: "dolphin", - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -527,7 +529,7 @@ test.serial("ui5 serve --no-workspace", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: null, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -564,7 +566,7 @@ test.serial("ui5 serve --workspace-config", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: fakePath, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -600,7 +602,7 @@ test.serial("ui5 serve --sap-csp-policies", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -636,7 +638,7 @@ test.serial("ui5 serve --serve-csp-reports", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -672,7 +674,7 @@ test.serial("ui5 serve --simple-index", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -715,7 +717,7 @@ test.serial("ui5 serve with ui5.yaml port setting", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -765,7 +767,7 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started @@ -822,7 +824,7 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting and port CLI argument", a t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, `Server started diff --git a/packages/cli/test/lib/cli/commands/tree.js b/packages/cli/test/lib/cli/commands/tree.js index f8e8fcad689..9d50acffb01 100644 --- a/packages/cli/test/lib/cli/commands/tree.js +++ b/packages/cli/test/lib/cli/commands/tree.js @@ -15,6 +15,8 @@ function getDefaultArgv() { "silent": false, "cache-mode": "Default", "cacheMode": "Default", + "snapshot-cache": "Default", + "snapshotCache": "Default", "flat": false, "level": undefined, "$0": "ui5" @@ -78,7 +80,7 @@ test.serial("ui5 tree (Without dependencies)", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -148,7 +150,7 @@ test.serial("ui5 tree", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -228,7 +230,7 @@ test.serial("ui5 tree --flat", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -305,7 +307,7 @@ test.serial("ui5 tree --level 1", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -395,7 +397,7 @@ test.serial("ui5 tree (With extensions)", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -441,7 +443,7 @@ test.serial("ui5 tree --perf", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -482,7 +484,7 @@ test.serial("ui5 tree --framework-version", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: "1.234.5", workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -495,10 +497,10 @@ ${chalk.italic("None")} `); }); -test.serial("ui5 tree --cache-mode", async (t) => { +test.serial("ui5 tree --snapshot-cache", async (t) => { const {argv, tree, traverseBreadthFirst, graph} = t.context; - argv.cacheMode = "Force"; + argv.snapshotCache = "Force"; traverseBreadthFirst.callsFake(async (fn) => { await fn({ @@ -521,7 +523,7 @@ test.serial("ui5 tree --cache-mode", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Force", + snapshotCache: "Force", }]); t.is(t.context.consoleOutput, @@ -561,7 +563,7 @@ test.serial("ui5 tree --config", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: fakePath, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -600,7 +602,7 @@ test.serial("ui5 tree --workspace", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: "dolphin", - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -639,7 +641,7 @@ test.serial("ui5 tree --no-workspace", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: null, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -679,7 +681,7 @@ test.serial("ui5 tree --workspace-config", async (t) => { t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: fakePath, workspaceName: undefined, - cacheMode: "Default", + snapshotCache: "Default", }]); t.is(t.context.consoleOutput, @@ -717,7 +719,7 @@ test.serial("ui5 tree --dependency-definition", async (t) => { t.is(graph.graphFromPackageDependencies.callCount, 0); t.is(graph.graphFromStaticFile.callCount, 1); t.deepEqual(graph.graphFromStaticFile.getCall(0).args, [{ - filePath: fakePath, versionOverride: undefined, cacheMode: "Default" + filePath: fakePath, versionOverride: undefined, snapshotCache: "Default" }]); t.is(t.context.consoleOutput, diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index ebeb9744e78..fbf500823bd 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -4,6 +4,7 @@ import BuildReader from "./BuildReader.js"; import WatchHandler from "./helpers/WatchHandler.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:BuildServer"); +import Cache from "./cache/Cache.js"; class AbortBuildError extends Error { constructor(message) { @@ -186,8 +187,10 @@ class BuildServer extends EventEmitter { throw new Error(`Project '${projectName}' not found in project graph`); } const projectBuildStatus = this.#projectBuildStatus.get(projectName); + const cacheMode = this.#projectBuilder._buildContext.getBuildConfig().cache; - if (projectBuildStatus.isFresh()) { + // When cache=Off, always rebuild - don't use in-memory cached readers + if (cacheMode !== Cache.Off && projectBuildStatus.isFresh()) { return projectBuildStatus.getReader(); } const {promise, resolve, reject} = Promise.withResolvers(); diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 429e4f4f69f..85c0caf8f53 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -32,6 +32,8 @@ class ProjectBuilder { * @property {Array.} [includedTasks=[]] List of tasks to be included * @property {Array.} [excludedTasks=[]] List of tasks to be excluded. * If the wildcard '*' is provided, only the included tasks will be executed. + * @property {module:@ui5/project/build/cache/Cache} [cache=Cache.Default] + * Cache mode to use for building UI5 projects */ /** diff --git a/packages/project/lib/build/cache/Cache.js b/packages/project/lib/build/cache/Cache.js new file mode 100644 index 00000000000..cf2dd4a1bd6 --- /dev/null +++ b/packages/project/lib/build/cache/Cache.js @@ -0,0 +1,18 @@ +/** + * Cache modes for building UI5 projects + * + * @public + * @readonly + * @enum {string} + * @property {string} Default Use cache if available + * @property {string} Force Use cache only (if it's unavailable or invalid, the build fails) + * @property {string} ReadOnly Do not create or update any cache but make use of a cache if available + * @property {string} Off Do not use any cache and always rebuild + * @module @ui5/project/build/cache/Cache + */ +export default { + Default: "Default", + Force: "Force", + ReadOnly: "ReadOnly", + Off: "Off" +}; diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 97370992380..3106a0f74cd 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -8,6 +8,7 @@ import StageCache from "./StageCache.js"; import ResourceIndex from "./index/ResourceIndex.js"; import {firstTruthy, matchResourceMetadataStrict} from "./utils.js"; const log = getLogger("build:cache:ProjectBuildCache"); +import Cache from "./Cache.js"; export const INDEX_STATES = Object.freeze({ RESTORING_PROJECT_INDICES: "restoring_project_indices", @@ -48,6 +49,7 @@ export default class ProjectBuildCache { #project; #buildSignature; #cacheManager; + #cacheMode; #currentProjectReader; #currentDependencyReader; #sourceIndex; @@ -80,13 +82,15 @@ export default class ProjectBuildCache { * @param {@ui5/project/specifications/Project} project Project instance * @param {string} buildSignature Build signature for the current build * @param {object} cacheManager Cache manager instance for reading/writing cache data + * @param {string} cacheMode Cache mode to use for building UI5 projects */ - constructor(project, buildSignature, cacheManager) { + constructor(project, buildSignature, cacheManager, cacheMode) { log.verbose( `ProjectBuildCache for project ${project.getName()} uses build signature ${buildSignature}`); this.#project = project; this.#buildSignature = buildSignature; this.#cacheManager = cacheManager; + this.#cacheMode = cacheMode; } /** @@ -99,10 +103,11 @@ export default class ProjectBuildCache { * @param {@ui5/project/specifications/Project} project Project instance * @param {string} buildSignature Build signature for the current build * @param {object} cacheManager Cache manager instance + * @param {string} cacheMode Cache mode to use for building UI5 projects * @returns {Promise<@ui5/project/build/cache/ProjectBuildCache>} Initialized cache instance */ - static async create(project, buildSignature, cacheManager) { - return new ProjectBuildCache(project, buildSignature, cacheManager); + static async create(project, buildSignature, cacheManager, cacheMode) { + return new ProjectBuildCache(project, buildSignature, cacheManager, cacheMode); } /** @@ -115,6 +120,10 @@ export default class ProjectBuildCache { * @returns {Promise} */ async initSourceIndex() { + // When cache=Off, always reinitialize to clear cached state + if (this.#cacheMode === Cache.Off && this.#combinedIndexState !== INDEX_STATES.RESTORING_PROJECT_INDICES) { + this.#combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES; + } if (this.#combinedIndexState !== INDEX_STATES.RESTORING_PROJECT_INDICES) { // Already initialized (e.g. reused across builds) return; @@ -179,6 +188,14 @@ export default class ProjectBuildCache { const changesDetected = await this.#flushPendingChanges(); if (changesDetected) { this.#resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; + // Force mode: Fail immediately if changes were detected + if (this.#cacheMode === Cache.Force) { + throw new Error( + `Cache is in "Force" mode but cache is stale for project ${this.#project.getName()} ` + + `due to detected source file changes. ` + + `Use "Default", "ReadOnly" or "Off" to rebuild.` + ); + } } if (log.isLevelEnabled("perf")) { log.perf( @@ -188,6 +205,14 @@ export default class ProjectBuildCache { this.#combinedIndexState = INDEX_STATES.FRESH; } + // When cache=Off, don't validate or use result cache + if (this.#cacheMode === Cache.Off) { + log.verbose(`Cache is in "Off" mode for project ${this.#project.getName()}. ` + + `Skipping result cache validation`); + this.#resultCacheState = RESULT_CACHE_STATES.NO_CACHE; + return false; + } + if (this.#resultCacheState === RESULT_CACHE_STATES.PENDING_VALIDATION) { log.verbose(`Project ${this.#project.getName()} cache requires validation due to detected changes.`); const findStart = performance.now(); @@ -288,6 +313,10 @@ export default class ProjectBuildCache { * @returns {boolean} True if the cache is fresh */ isFresh() { + // When cache=Off, always return false to force rebuilds + if (this.#cacheMode === Cache.Off) { + return false; + } return this.#combinedIndexState === INDEX_STATES.FRESH && this.#resultCacheState === RESULT_CACHE_STATES.FRESH_AND_IN_USE; } @@ -1268,6 +1297,25 @@ export default class ProjectBuildCache { */ async #initSourceIndex() { const sourceReader = this.#project.getSourceReader(); + + if (this.#cacheMode === Cache.Off) { + // Caching disabled: Create fresh index + log.verbose(`Cache is in "Off" mode. ` + + `Initializing fresh source index for project ${this.#project.getName()}`); + this.#sourceIndex = await ResourceIndex.create(await sourceReader.byGlob("/**/*"), + Date.now()); + this.#combinedIndexState = INDEX_STATES.INITIAL; + // Clear any existing task cache from previous builds + this.#taskCache.clear(); + this.#stageCache = new StageCache(); + // Reset ProjectResources to initial stage if it exists (clear any cached result stage) + const currentStage = this.#project.getProjectResources().getStage(); + if (currentStage && currentStage.getId() !== "initial") { + this.#project.getProjectResources().useStage("initial"); + } + return; + } + const [resources, indexCache] = await Promise.all([ await sourceReader.byGlob("/**/*"), await this.#cacheManager.readIndexCache(this.#project.getId(), this.#buildSignature, "source"), @@ -1318,6 +1366,17 @@ export default class ProjectBuildCache { this.#taskCache.set(buildTaskCache.getTaskName(), buildTaskCache); } + // Force mode: Fail if cache is stale (source files changed OR pending changes exist) + if (this.#cacheMode === Cache.Force && + (changedPaths.length > 0 || this.#changedProjectSourcePaths.length > 0)) { + const totalChanges = changedPaths.length + this.#changedProjectSourcePaths.length; + throw new Error( + `Cache is in "Force" mode but cache is stale for project ${this.#project.getName()} ` + + `due to ${totalChanges} changed source file(s). ` + + `Use "Default", "ReadOnly" or "Off" to rebuild.` + ); + } + if (!changedPaths.length) { // Source index is up-to-date with no changes this.#cachedSourceSignature = resourceIndex.getSignature(); @@ -1328,6 +1387,10 @@ export default class ProjectBuildCache { // Now awaiting initialization of dependency indices this.#combinedIndexState = INDEX_STATES.RESTORING_DEPENDENCY_INDICES; } else { + if (this.#cacheMode === Cache.Force) { + throw new Error(`Cache is in "Force" mode but no cache found for project ${this.#project.getName()}. ` + + `Use "Default", "ReadOnly" or "Off" to rebuild.`); + } // No index cache found, create new index this.#sourceIndex = await ResourceIndex.create(resources, Date.now()); this.#combinedIndexState = INDEX_STATES.INITIAL; @@ -1390,6 +1453,16 @@ export default class ProjectBuildCache { * @returns {Promise} */ async writeCache() { + // OFF or ReadOnly modes: Skip all cache writes + if (this.#cacheMode === Cache.Off || this.#cacheMode === Cache.ReadOnly) { + log.verbose( + `Skipping cache write for project ${this.#project.getName()} ` + + `(cache mode: ${this.#cacheMode})` + ); + return; + } + + // Default and Force modes: Write cache normally const cacheWriteStart = performance.now(); this.#cacheManager.beginMetadataBatch(); try { diff --git a/packages/project/lib/build/helpers/BuildContext.js b/packages/project/lib/build/helpers/BuildContext.js index ee868ab3b3b..c14308fb103 100644 --- a/packages/project/lib/build/helpers/BuildContext.js +++ b/packages/project/lib/build/helpers/BuildContext.js @@ -4,6 +4,7 @@ import CacheManager from "../cache/CacheManager.js"; import {getBaseSignature} from "./getBuildSignature.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:helpers:BuildContext"); +import Cache from "../cache/Cache.js"; /** * Context of a build process @@ -21,6 +22,7 @@ class BuildContext { createBuildManifest = false, outputStyle = OutputStyleEnum.Default, includedTasks = [], excludedTasks = [], + cache = Cache.Default, } = {}) { if (!graph) { throw new Error(`Missing parameter 'graph'`); @@ -73,8 +75,11 @@ class BuildContext { outputStyle, includedTasks, excludedTasks, + cache }; - this._buildSignatureBase = getBaseSignature(this._buildConfig); + // eslint-disable-next-line no-unused-vars + const {cache: _ignoreMe, ...signatureConfig} = this._buildConfig; // Clones buildConfig omitting the cache mode + this._buildSignatureBase = getBaseSignature(signatureConfig); this._taskRepository = taskRepository; diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index 426fecb140a..bed574f9e02 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -56,8 +56,9 @@ class ProjectBuildContext { static async create(buildContext, project, cacheManager, baseSignature) { const buildSignature = getProjectSignature( baseSignature, project, buildContext.getGraph(), buildContext.getTaskRepository()); + const cacheMode = buildContext.getBuildConfig().cache; const buildCache = await ProjectBuildCache.create( - project, buildSignature, cacheManager); + project, buildSignature, cacheManager, cacheMode); return new ProjectBuildContext( buildContext, project, diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index f3d2ccc0384..b0d8f1cfaf0 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,6 +1,7 @@ import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); +import Cache from "../../../project/lib/build/cache/Cache.js"; /** @@ -713,6 +714,8 @@ class ProjectGraph { * @param {Array.} [parameters.excludedTasks=[]] List of tasks to be excluded. * @param {module:@ui5/project/build/ProjectBuilderOutputStyle} [parameters.outputStyle=Default] * Processes build results into a specific directory structure. + * @param {module:@ui5/project/build/cache/Cache} [parameters.cache=Default] + * Cache mode to use for building UI5 projects * @returns {Promise} Promise resolving to undefined once build has finished */ async build({ @@ -722,6 +725,7 @@ class ProjectGraph { selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], outputStyle = OutputStyleEnum.Default, + cache = Cache.Default, }) { this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { @@ -740,6 +744,7 @@ class ProjectGraph { selfContained, cssVariables, jsdoc, createBuildManifest, includedTasks, excludedTasks, outputStyle, + cache } }); return await builder.buildToTarget({ @@ -754,6 +759,7 @@ class ProjectGraph { initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], + cache = Cache.Default, }) { this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { @@ -773,6 +779,7 @@ class ProjectGraph { createBuildManifest, includedTasks, excludedTasks, outputStyle: OutputStyleEnum.Default, + cache } }); const { diff --git a/packages/project/lib/graph/graph.js b/packages/project/lib/graph/graph.js index 0885265447f..bf1bd5c3583 100644 --- a/packages/project/lib/graph/graph.js +++ b/packages/project/lib/graph/graph.js @@ -31,8 +31,8 @@ const log = getLogger("generateProjectGraph"); * Whether framework dependencies should be added to the graph * @param {string|null} [options.workspaceName=default] * Name of the workspace configuration that should be used. "default" if not provided. - * @param {module:@ui5/project/ui5Framework/maven/CacheMode} [options.cacheMode] - * Cache mode to use when consuming SNAPSHOT versions of a framework + * @param {module:@ui5/project/ui5Framework/maven/SnapshotCache} [options.snapshotCache] + * Snapshot cache mode to use when consuming SNAPSHOT versions of a framework * @param {string} [options.workspaceConfigPath=ui5-workspace.yaml] * Workspace configuration file to use if no object has been provided * @param {@ui5/project/graph/Workspace~Configuration} [options.workspaceConfiguration] @@ -42,7 +42,7 @@ const log = getLogger("generateProjectGraph"); */ export async function graphFromPackageDependencies({ cwd, rootConfiguration, rootConfigPath, - versionOverride, cacheMode, resolveFrameworkDependencies = true, + versionOverride, snapshotCache, resolveFrameworkDependencies = true, workspaceName="default", workspaceConfiguration, workspaceConfigPath = "ui5-workspace.yaml" }) { @@ -73,7 +73,7 @@ export async function graphFromPackageDependencies({ const projectGraph = await projectGraphBuilder(provider, workspace); if (resolveFrameworkDependencies) { - await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, cacheMode, workspace}); + await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, snapshotCache, workspace}); } return projectGraph; @@ -98,8 +98,8 @@ export async function graphFromPackageDependencies({ * Configuration file to use for the root module instead the default ui5.yaml. Either a path relative to * cwd or an absolute path. In both case, platform-specific path segment separators must be used. * @param {string} [options.versionOverride] Framework version to use instead of the one defined in the root project - * @param {module:@ui5/project/ui5Framework/maven/CacheMode} [options.cacheMode] - * Cache mode to use when consuming SNAPSHOT versions of a framework + * @param {module:@ui5/project/ui5Framework/maven/SnapshotCache} [options.snapshotCache] + * Snapshot cache mode to use when consuming SNAPSHOT versions of a framework * @param {string} [options.resolveFrameworkDependencies=true] * Whether framework dependencies should be added to the graph * @returns {Promise<@ui5/project/graph/ProjectGraph>} Promise resolving to a Project Graph instance @@ -107,7 +107,7 @@ export async function graphFromPackageDependencies({ export async function graphFromStaticFile({ filePath = "projectDependencies.yaml", cwd, rootConfiguration, rootConfigPath, - versionOverride, cacheMode, resolveFrameworkDependencies = true + versionOverride, snapshotCache, resolveFrameworkDependencies = true }) { log.verbose(`Creating project graph using static file...`); const { @@ -128,7 +128,7 @@ export async function graphFromStaticFile({ const projectGraph = await projectGraphBuilder(provider); if (resolveFrameworkDependencies) { - await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, cacheMode}); + await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, snapshotCache}); } return projectGraph; @@ -150,8 +150,8 @@ export async function graphFromStaticFile({ * Configuration file to use for the root module instead the default ui5.yaml. Either a path relative to * cwd or an absolute path. In both case, platform-specific path segment separators must be used. * @param {string} [options.versionOverride] Framework version to use instead of the one defined in the root project - * @param {module:@ui5/project/ui5Framework/maven/CacheMode} [options.cacheMode] - * Cache mode to use when consuming SNAPSHOT versions of a framework + * @param {module:@ui5/project/ui5Framework/maven/SnapshotCache} [options.snapshotCache] + * Snapshot cache mode to use when consuming SNAPSHOT versions of a framework * @param {string} [options.resolveFrameworkDependencies=true] * Whether framework dependencies should be added to the graph * @returns {Promise<@ui5/project/graph/ProjectGraph>} Promise resolving to a Project Graph instance @@ -159,7 +159,7 @@ export async function graphFromStaticFile({ export async function graphFromObject({ dependencyTree, cwd, rootConfiguration, rootConfigPath, - versionOverride, cacheMode, resolveFrameworkDependencies = true + versionOverride, snapshotCache, resolveFrameworkDependencies = true }) { log.verbose(`Creating project graph using object...`); const { @@ -178,7 +178,7 @@ export async function graphFromObject({ const projectGraph = await projectGraphBuilder(dependencyTreeProvider); if (resolveFrameworkDependencies) { - await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, cacheMode}); + await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, snapshotCache}); } return projectGraph; diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index 19737a4bd7b..660cc78427e 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -282,15 +282,15 @@ export default { * @param {object} [options] * @param {string} [options.versionOverride] Framework version to use instead of the root projects framework * version - * @param {module:@ui5/project/ui5Framework/maven/CacheMode} [options.cacheMode] - * Cache mode to use when consuming SNAPSHOT versions of a framework + * @param {module:@ui5/project/ui5Framework/maven/SnapshotCache} [options.snapshotCache] + * Snapshot cache mode to use when consuming SNAPSHOT versions of a framework * @param {@ui5/project/graph/Workspace} [options.workspace] * Optional workspace instance to use for overriding node resolutions * @returns {Promise<@ui5/project/graph/ProjectGraph>} * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { - const {workspace, cacheMode} = options; + const {workspace, snapshotCache} = options; const rootProject = projectGraph.getRoot(); const frameworkName = rootProject.getFrameworkName(); const frameworkVersion = rootProject.getFrameworkVersion(); @@ -386,7 +386,7 @@ export default { cwd, version, providedLibraryMetadata, - cacheMode, + snapshotCache, ui5DataDir }); diff --git a/packages/project/lib/ui5Framework/Sapui5MavenSnapshotResolver.js b/packages/project/lib/ui5Framework/Sapui5MavenSnapshotResolver.js index 7002bddbd27..01c4b843541 100644 --- a/packages/project/lib/ui5Framework/Sapui5MavenSnapshotResolver.js +++ b/packages/project/lib/ui5Framework/Sapui5MavenSnapshotResolver.js @@ -34,21 +34,21 @@ class Sapui5MavenSnapshotResolver extends AbstractResolver { * @param {string} [options.cwd=process.cwd()] Current working directory * @param {string} [options.ui5DataDir="~/.ui5"] UI5 home directory location. This will be used to store packages, * metadata and configuration used by the resolvers. Relative to `process.cwd()` - * @param {module:@ui5/project/ui5Framework/maven/CacheMode} [options.cacheMode=Default] - * Cache mode to use + * @param {module:@ui5/project/ui5Framework/maven/SnapshotCache} [options.snapshotCache=Default] + * Snapshot cache mode to use */ constructor(options) { super(options); const { - cacheMode, + snapshotCache, } = options; this._installer = new Installer({ ui5DataDir: this._ui5DataDir, snapshotEndpointUrlCb: Sapui5MavenSnapshotResolver._createSnapshotEndpointUrlCallback(options.snapshotEndpointUrl), - cacheMode, + snapshotCache, }); this._loadDistMetadata = null; diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 4dd6d1bc8cd..2c8e45fb7f6 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -6,7 +6,7 @@ const StreamZip = _StreamZip.async; import {promisify} from "node:util"; import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; -import CacheMode from "./CacheMode.js"; +import SnapshotCache from "./SnapshotCache.js"; import {rmrf} from "../../utils/fs.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); @@ -27,9 +27,10 @@ class Installer extends AbstractInstaller { * @param {Function} parameters.snapshotEndpointUrlCb Callback that returns a Promise , * resolving to the Maven repository URL. * Example: https://registry.corp/vendor/build-snapshots/ - * @param {module:@ui5/project/ui5Framework/maven/CacheMode} [parameters.cacheMode=Default] Cache mode to use + * @param {module:@ui5/project/ui5Framework/maven/SnapshotCache} [parameters.snapshotCache=Default] + * Snapshot cache mode to use */ - constructor({ui5DataDir, snapshotEndpointUrlCb, cacheMode = CacheMode.Default}) { + constructor({ui5DataDir, snapshotEndpointUrlCb, snapshotCache = SnapshotCache.Default}) { super(ui5DataDir); this._artifactsDir = path.join(ui5DataDir, "framework", "artifacts"); @@ -37,20 +38,20 @@ class Installer extends AbstractInstaller { this._metadataDir = path.join(ui5DataDir, "framework", "metadata"); this._stagingDir = path.join(ui5DataDir, "framework", "staging"); - this._cacheMode = cacheMode; + this._snapshotCache = snapshotCache; this._snapshotEndpointUrlCb = snapshotEndpointUrlCb; if (!this._snapshotEndpointUrlCb) { throw new Error(`Installer: Missing Snapshot-Endpoint URL callback parameter`); } - if (!Object.values(CacheMode).includes(cacheMode)) { - throw new Error(`Installer: Invalid value '${cacheMode}' for cacheMode parameter. ` + - `Must be one of ${Object.values(CacheMode).join(", ")}`); + if (!Object.values(SnapshotCache).includes(snapshotCache)) { + throw new Error(`Installer: Invalid value '${snapshotCache}' for snapshotCache parameter. ` + + `Must be one of ${Object.values(SnapshotCache).join(", ")}`); } log.verbose(`Installing Maven artifacts to: ${this._artifactsDir}`); log.verbose(`Installing Packages to: ${this._packagesDir}`); - log.verbose(`Caching mode: ${this._cacheMode}`); + log.verbose(`Snapshot cache mode: ${this._snapshotCache}`); } async getRegistry() { @@ -122,7 +123,7 @@ class Installer extends AbstractInstaller { return this._synchronize("metadata-" + fsId, async () => { const localMetadata = await this._getLocalArtifactMetadata(fsId); - if (this._cacheMode === CacheMode.Force && !localMetadata.revision) { + if (this._snapshotCache === SnapshotCache.Force && !localMetadata.revision) { throw new Error(`Could not find artifact ` + `${logId} in local cache`); } @@ -130,8 +131,8 @@ class Installer extends AbstractInstaller { const now = new Date().getTime(); const timeSinceLastCheck = now - localMetadata.lastCheck; - if (this._cacheMode !== CacheMode.Force && - (timeSinceLastCheck > CACHE_TIME || this._cacheMode === CacheMode.Off)) { + if (this._snapshotCache !== SnapshotCache.Force && + (timeSinceLastCheck > CACHE_TIME || this._snapshotCache === SnapshotCache.Off)) { // No cached metadata (-> timeSinceLastCheck equals time since 1970) or // too old metadata or disabled cache // => Retrieve metadata from repository diff --git a/packages/project/lib/ui5Framework/maven/Registry.js b/packages/project/lib/ui5Framework/maven/Registry.js index ec5b2293e75..7003c49b230 100644 --- a/packages/project/lib/ui5Framework/maven/Registry.js +++ b/packages/project/lib/ui5Framework/maven/Registry.js @@ -65,8 +65,8 @@ class Registry { `You can change the configured URL using the following command: ` + `'ui5 config set mavenSnapshotEndpointUrl '`); - // TODO: Allow cacheMode to be set from outside - // `You may be able to continue working offline. For this, set --cache-mode to "force"`); + // TODO: Allow snapshotCache to be set from outside + // `You may be able to continue working offline. For this, set --snapshot-cache to "force"`); // ` or use the --offline flag`); // TODO: Implement --offline flag } throw new Error( @@ -108,8 +108,8 @@ class Registry { `You can change the configured URL using the following command: ` + `'ui5 config set mavenSnapshotEndpointUrl '`); - // TODO: Allow cacheMode to be set from outside - // `You may be able to continue working offline. For this, set --cache-mode to "force"`); + // TODO: Allow snapshotCache to be set from outside + // `You may be able to continue working offline. For this, set --snapshot-cache to "force"`); // ` or use the --offline flag`); // TODO: Implement --offline flag } throw new Error(`Failed to retrieve artifact ` + diff --git a/packages/project/lib/ui5Framework/maven/CacheMode.js b/packages/project/lib/ui5Framework/maven/SnapshotCache.js similarity index 77% rename from packages/project/lib/ui5Framework/maven/CacheMode.js rename to packages/project/lib/ui5Framework/maven/SnapshotCache.js index d1b5af0d422..5e7927090f6 100644 --- a/packages/project/lib/ui5Framework/maven/CacheMode.js +++ b/packages/project/lib/ui5Framework/maven/SnapshotCache.js @@ -1,7 +1,7 @@ /** - * Cache modes for maven consumption + * Snapshot cache modes for Maven consumption * * @public * @readonly @@ -9,7 +9,7 @@ * @property {string} Default Cache everything, invalidate after 9 hours * @property {string} Force Use cache only. Do not send any requests to the repository * @property {string} Off Invalidate the cache and update from the repository - * @module @ui5/project/ui5Framework/maven/CacheMode + * @module @ui5/project/ui5Framework/maven/SnapshotCache */ export default { Default: "Default", diff --git a/packages/project/package.json b/packages/project/package.json index b571af7fc37..22f9a8ce37f 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -19,12 +19,13 @@ "type": "module", "exports": { "./config/Configuration": "./lib/config/Configuration.js", + "./build/cache/Cache": "./lib/build/cache/Cache.js", "./specifications/Specification": "./lib/specifications/Specification.js", "./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js", "./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js", "./ui5Framework/Openui5Resolver": "./lib/ui5Framework/Openui5Resolver.js", "./ui5Framework/Sapui5Resolver": "./lib/ui5Framework/Sapui5Resolver.js", - "./ui5Framework/maven/CacheMode": "./lib/ui5Framework/maven/CacheMode.js", + "./ui5Framework/maven/SnapshotCache": "./lib/ui5Framework/maven/SnapshotCache.js", "./validation/validator": "./lib/validation/validator.js", "./validation/ValidationError": "./lib/validation/ValidationError.js", "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", diff --git a/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/.library b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/.library new file mode 100644 index 00000000000..53c2d14c9d6 --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + Some fancy copyright + ${version} + + Library D + + diff --git a/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/data.json b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/data.json new file mode 100644 index 00000000000..9658000784b --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/data.json @@ -0,0 +1 @@ +{"key": "original-value"} diff --git a/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/some.js b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/some.js new file mode 100644 index 00000000000..81e73436075 --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/src/library/d/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); \ No newline at end of file diff --git a/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/test/library/d/Test.html b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/main/test/library/d/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/application.i.copy/node_modules/library.d/package.json b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/package.json new file mode 100644 index 00000000000..90c75040abe --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.d", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/application.i.copy/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/ui5.yaml new file mode 100644 index 00000000000..a47c1f64c3d --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/node_modules/library.d/ui5.yaml @@ -0,0 +1,10 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.d +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/packages/project/test/fixtures/application.i.copy/package.json b/packages/project/test/fixtures/application.i.copy/package.json new file mode 100644 index 00000000000..23a233d70c8 --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/package.json @@ -0,0 +1,12 @@ +{ + "name": "application.i.copy", + "version": "1.0.0", + "description": "Simple SAPUI5 based application", + "main": "index.html", + "dependencies": { + "library.d": "file:../library.d" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/application.i.copy/ui5.yaml b/packages/project/test/fixtures/application.i.copy/ui5.yaml new file mode 100644 index 00000000000..8da9f4504d4 --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: application +metadata: + name: application.i.copy diff --git a/packages/project/test/fixtures/application.i.copy/webapp/index.html b/packages/project/test/fixtures/application.i.copy/webapp/index.html new file mode 100644 index 00000000000..1b8755901bf --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/webapp/index.html @@ -0,0 +1,9 @@ + + + + Application A + + + + + diff --git a/packages/project/test/fixtures/application.i.copy/webapp/manifest.json b/packages/project/test/fixtures/application.i.copy/webapp/manifest.json new file mode 100644 index 00000000000..781945df9dc --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/webapp/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "id1", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} \ No newline at end of file diff --git a/packages/project/test/fixtures/application.i.copy/webapp/test.js b/packages/project/test/fixtures/application.i.copy/webapp/test.js new file mode 100644 index 00000000000..a3df410c341 --- /dev/null +++ b/packages/project/test/fixtures/application.i.copy/webapp/test.js @@ -0,0 +1,5 @@ +function test(paramA) { + var variableA = paramA; + console.log(variableA); +} +test(); diff --git a/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/.library b/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/.library new file mode 100644 index 00000000000..53c2d14c9d6 --- /dev/null +++ b/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + Some fancy copyright + ${version} + + Library D + + diff --git a/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/data.json b/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/data.json new file mode 100644 index 00000000000..9658000784b --- /dev/null +++ b/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/data.json @@ -0,0 +1 @@ +{"key": "original-value"} diff --git a/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/some.js b/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/some.js new file mode 100644 index 00000000000..81e73436075 --- /dev/null +++ b/packages/project/test/fixtures/application.i/node_modules/library.d/main/src/library/d/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); \ No newline at end of file diff --git a/packages/project/test/fixtures/application.i/node_modules/library.d/main/test/library/d/Test.html b/packages/project/test/fixtures/application.i/node_modules/library.d/main/test/library/d/Test.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/application.i/node_modules/library.d/package.json b/packages/project/test/fixtures/application.i/node_modules/library.d/package.json new file mode 100644 index 00000000000..90c75040abe --- /dev/null +++ b/packages/project/test/fixtures/application.i/node_modules/library.d/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.d", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/application.i/node_modules/library.d/ui5.yaml b/packages/project/test/fixtures/application.i/node_modules/library.d/ui5.yaml new file mode 100644 index 00000000000..a47c1f64c3d --- /dev/null +++ b/packages/project/test/fixtures/application.i/node_modules/library.d/ui5.yaml @@ -0,0 +1,10 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.d +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/packages/project/test/fixtures/application.i/package.json b/packages/project/test/fixtures/application.i/package.json new file mode 100644 index 00000000000..12d3de627b3 --- /dev/null +++ b/packages/project/test/fixtures/application.i/package.json @@ -0,0 +1,12 @@ +{ + "name": "application.i", + "version": "1.0.0", + "description": "Simple SAPUI5 based application", + "main": "index.html", + "dependencies": { + "library.d": "file:../library.d" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/application.i/ui5.yaml b/packages/project/test/fixtures/application.i/ui5.yaml new file mode 100644 index 00000000000..33a7ccb97f7 --- /dev/null +++ b/packages/project/test/fixtures/application.i/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: application +metadata: + name: application.i diff --git a/packages/project/test/fixtures/application.i/webapp/index.html b/packages/project/test/fixtures/application.i/webapp/index.html new file mode 100644 index 00000000000..1b8755901bf --- /dev/null +++ b/packages/project/test/fixtures/application.i/webapp/index.html @@ -0,0 +1,9 @@ + + + + Application A + + + + + diff --git a/packages/project/test/fixtures/application.i/webapp/manifest.json b/packages/project/test/fixtures/application.i/webapp/manifest.json new file mode 100644 index 00000000000..781945df9dc --- /dev/null +++ b/packages/project/test/fixtures/application.i/webapp/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "id1", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} \ No newline at end of file diff --git a/packages/project/test/fixtures/application.i/webapp/test.js b/packages/project/test/fixtures/application.i/webapp/test.js new file mode 100644 index 00000000000..a3df410c341 --- /dev/null +++ b/packages/project/test/fixtures/application.i/webapp/test.js @@ -0,0 +1,5 @@ +function test(paramA) { + var variableA = paramA; + console.log(variableA); +} +test(); diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 6039881410b..4e999796b1f 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -5,6 +5,7 @@ import {setTimeout} from "node:timers/promises"; import fs from "node:fs/promises"; import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; import {setLogLevel} from "@ui5/logger"; +import Cache from "../../../lib/build/cache/Cache.js"; // Ensures that all logging code paths are tested setLogLevel("silly"); @@ -355,6 +356,262 @@ test.serial("Serve application.a, request application resource AND library resou ); }); +test.serial("Serve application.a with --cache=Default", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + // #1: Serve and request with empty cache --> all tasks execute + await fixtureTester.serveProject({config: {cache: Cache.Default}}); + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2: Request with valid cache, no changes --> nothing rebuilds (all cached) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: {} + } + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added for cache test");\n`); + + await setTimeout(500); // Wait for the file watcher to detect and propagate the changes + + // #3: Request with valid cache, source changes --> only affected tasks rebuild + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Verify the changed file is served + const resource = await fixtureTester.requestResource({resource: "/test.js"}); + const servedFileContent = await resource.getString(); + t.true(servedFileContent.includes(`test("line added for cache test");`), + "Served resource contains changed file content"); +}); + +test.serial("Serve application.a with --cache=Off", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + // #1: Serve and request with cache=Off --> all tasks execute, cache not written + await fixtureTester.serveProject({config: {cache: Cache.Off}}); + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2: Request with cache=Off (again) --> all tasks execute again (no cache reuse) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Restart server with cache=Default + await fixtureTester.teardown(); + await fixtureTester.serveProject({config: {cache: Cache.Default}}); + + // #3: Request with cache=Default --> all tasks execute (no cache from previous Off mode) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #4: Request with cache=Default (again) --> nothing rebuilds (cache now exists) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: {} + } + }); + + // Restart server with cache=Off + await fixtureTester.teardown(); + await fixtureTester.serveProject({config: {cache: Cache.Off}}); + + // #5: Request with cache=Off --> all tasks execute (ignores existing cache) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); +}); + +test.serial("Serve application.a with --cache=ReadOnly", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + // #1: Serve and request with cache=Default --> all tasks execute, cache written + await fixtureTester.serveProject({config: {cache: Cache.Default}}); + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Restart server with ReadOnly mode + await fixtureTester.teardown(); + await fixtureTester.serveProject({config: {cache: Cache.ReadOnly}}); + + // #2: Request with cache=ReadOnly, no changes --> nothing rebuilds (cache used) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: {} + } + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added for ReadOnly test");\n`); + + await setTimeout(500); // Wait for the file watcher to detect and propagate the changes + + // #3: Request with cache=ReadOnly --> affected tasks rebuild, BUT cache not updated + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Verify the changed file is served + const resource = await fixtureTester.requestResource({resource: "/test.js"}); + const servedFileContent = await resource.getString(); + t.true(servedFileContent.includes(`test("line added for ReadOnly test");`), + "Served resource contains changed file content"); + + // Restart server with Default mode + await fixtureTester.teardown(); + await fixtureTester.serveProject({config: {cache: Cache.Default}}); + + // #4: Request with cache=Default, no new changes --> rebuilds again (cache from #3 missing) + // This validates that ReadOnly didn't write the cache in step #3 + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); +}); + +test.serial("Serve application.a with --cache=Force (1)", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + // #1: Serve and request with cache=Default --> all tasks execute, cache written + await fixtureTester.serveProject({config: {cache: Cache.Default}}); + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": {} + } + } + }); + + // Restart server with Force mode + await fixtureTester.teardown(); + await fixtureTester.serveProject({config: {cache: Cache.Force}, expectBuildErrors: true}); + + // #2: Request with cache=Force, no changes --> nothing rebuilds (cache used) + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: {} + } + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added for Force test");\n`); + + await setTimeout(500); // Wait for the file watcher to detect and propagate the changes + + // #3: Request with cache=Force --> ERROR (cache invalid due to source changes) + const error = await t.throwsAsync(async () => { + await fixtureTester.requestResource({ + resource: "/test.js", + }); + }); + + t.truthy(error, "Request with Force mode should throw error when cache is stale"); + t.true(error.message.includes(`Cache is in "Force" mode but cache is stale for project application.a`)); + + // Wait for async error handling to complete + await setTimeout(50); +}); + +test.serial("Serve application.a with --cache=Force (2)", async (t) => { + const fixtureTester = t.context.fixtureTester = new FixtureTester(t, "application.a"); + + // #1: Serve with cache=Force on empty cache --> ERROR when requesting resource + await fixtureTester.serveProject({config: {cache: Cache.Force}, expectBuildErrors: true}); + + const error = await t.throwsAsync(async () => { + await fixtureTester.requestResource({ + resource: "/test.js", + }); + }); + + t.truthy(error, "Request with Force mode should throw error when cache is empty"); + t.true(error.message.includes(`Cache is in "Force" mode but no cache found for project application.a`)); + + // Wait for async error handling to complete + await setTimeout(50); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } @@ -392,11 +649,15 @@ class FixtureTester { async teardown() { if (this.buildServer) { - await this.buildServer.destroy(); + try { + await this.buildServer.destroy(); + } catch { + // Ignore errors during teardown (e.g., failed Force mode builds) + } } } - async serveProject({graphConfig = {}, config = {}} = {}) { + async serveProject({graphConfig = {}, config = {}, expectBuildErrors = false} = {}) { await this._initialize(); const graph = this.graph = await graphFromPackageDependencies({ @@ -407,7 +668,9 @@ class FixtureTester { // Execute the build this.buildServer = await graph.serve(config); this.buildServer.on("error", (err) => { - this._t.fail(`Build server error: ${err.message}`); + if (!expectBuildErrors) { + this._t.fail(`Build server error: ${err.message}`); + } }); this._reader = this.buildServer.getReader(); } diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index 8662471cefc..d178875a666 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -4,6 +4,7 @@ import {fileURLToPath} from "node:url"; import fs from "node:fs/promises"; import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; import {setLogLevel} from "@ui5/logger"; +import Cache from "../../../lib/build/cache/Cache.js"; // Ensures that all logging code paths are tested setLogLevel("silly"); @@ -2451,6 +2452,274 @@ test.serial("Build with dependencies: Verify sap-ui-version.json generation and "buildTimestamp unchanged when cached (no source changes)"); }); +test.serial("Build application.a with --cache=Default", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 Build with empty cache --> all tasks execute + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, cache: Cache.Default}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2 Build with valid cache, no changes --> nothing rebuilds (all cached) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Default}, + assertions: { + projects: {} + } + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added for cache test");\n`); + + // #3 Build with valid cache, source changes --> only affected tasks rebuild + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Default}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Verify the changed file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`test("line added for cache test");`), + "Build dest contains changed file content"); +}); + +test.serial("Build application.a with --cache=Off", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 Build with cache=Off --> all tasks execute, cache not written + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, cache: Cache.Off}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2 Build with cache=Off (again) --> all tasks execute again (no cache reuse) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Off}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #3 Build with cache=Default --> all tasks execute (no cache from previous builds) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Default}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #4 Build with cache=Default (again) --> nothing rebuilds (cache now exists) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Default}, + assertions: { + projects: {} + } + }); + + // #5 Build with cache=Off --> all tasks execute (ignores existing cache) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Off}, + assertions: { + projects: { + "application.a": {} + } + } + }); +}); + +test.serial("Build application.a with --cache=ReadOnly", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 Build with cache=Default --> all tasks execute, cache written + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, cache: Cache.Default}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2 Build with cache=ReadOnly, no changes --> nothing rebuilds (cache used) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.ReadOnly}, + assertions: { + projects: {} + } + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added for ReadOnly test");\n`); + + // #3 Build with cache=ReadOnly --> affected tasks rebuild, BUT cache not updated + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.ReadOnly}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); + + // Verify the changed file is in the destPath + const builtFileContent = await fs.readFile(`${destPath}/test.js`, {encoding: "utf8"}); + t.true(builtFileContent.includes(`test("line added for ReadOnly test");`), + "Build dest contains changed file content"); + + // #4 Build with cache=Default, no new changes --> rebuilds again (cache from #3 missing) + // This validates that ReadOnly didn't write the cache in step #3 + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Default}, + assertions: { + projects: { + "application.a": { + skippedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "enhanceManifest", + "generateFlexChangesBundle", + ] + } + } + } + }); +}); + +test.serial("Build application.a with --cache=Force (1)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1: Build with cache=Default --> all tasks execute, cache written + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, cache: Cache.Default}, + assertions: { + projects: { + "application.a": {} + } + } + }); + + // #2: Build with cache=Force, no changes --> nothing rebuilds (cache used) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Force}, + assertions: { + projects: {} + } + }); + + // Change a source file in application.a + const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; + await fs.appendFile(changedFilePath, `\ntest("line added for Force test");\n`); + + // #3: Build with cache=Force --> ERROR (cache invalid due to source changes) + const error = await t.throwsAsync(async () => { + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, cache: Cache.Force}, + }); + }); + + t.truthy(error, "Build with Force mode should throw error when cache is stale"); + t.true(error.message.includes(`Cache is in "Force" mode but cache is stale for project application.a ` + + `due to 1 changed source file(s). ` + + `Use "Default", "ReadOnly" or "Off" to rebuild.`)); +}); + +test.serial("Build application.a with --cache=Force (2)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1: Build with cache=Force on empty cache --> ERROR with clear message + const error = await t.throwsAsync(async () => { + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, cache: Cache.Force}, + }); + }); + + t.truthy(error, "Build with Force mode should throw error when cache is empty"); + t.true(error.message.includes(`Cache is in "Force" mode but no cache found for project application.a. ` + + `Use "Default", "ReadOnly" or "Off" to rebuild.`)); +}); + +// FIXME: Currently failing at #2 Build assertion +test.serial("Build application.i and application.i.copy with --cache", async (t) => { + // This test covers a scenario with two projects depending on the same + // dependency (exact same content) "library.d". + // We want to verify that: + // - First, we only want to build application.i (with cache=Default) + // (caches for the root project and the dependency should be created) + // - Second, we build application.i.copy with cache=Default + // (since application.i.copy has the same dependency "library.d" + // with the exact same content, the cache for library.d can be reused. + + const fixtureTester1 = new FixtureTester(t, "application.i"); + const destPath1 = fixtureTester1.destPath; + + // #1: Build application.i with cache=Default + await fixtureTester1.buildProject({ + config: {destPath: destPath1, cleanDest: false, cache: Cache.Default, + dependencyIncludes: {includeAllDependencies: true} + }, + assertions: { + projects: { + "library.d": {}, + "application.i": {}, + }, + } + }); + + + // #2: Build application.i.copy with cache=Default + const fixtureTester2 = new FixtureTester(t, "application.i.copy"); + const destPath2 = fixtureTester2.destPath; + + await fixtureTester2.buildProject({ + config: {destPath: destPath2, cleanDest: false, cache: Cache.Default, + dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + // library.d should not be rebuilt + "application.i.copy": {}, + }, + } + }); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } @@ -2485,7 +2754,7 @@ class FixtureTester { this._initialized = true; } - async buildProject({graphConfig = {}, config = {}, assertions = {}} = {}) { + async buildProject({graphConfig = {}, config = {}, assertions} = {}) { await this._initialize(); this._sinon.resetHistory(); diff --git a/packages/project/test/lib/graph/graph.integration.js b/packages/project/test/lib/graph/graph.integration.js index 9b459a0f823..fcff4e57957 100644 --- a/packages/project/test/lib/graph/graph.integration.js +++ b/packages/project/test/lib/graph/graph.integration.js @@ -3,7 +3,7 @@ import path from "node:path"; import sinonGlobal from "sinon"; import esmock from "esmock"; import Workspace from "../../../lib/graph/Workspace.js"; -import CacheMode from "../../../lib/ui5Framework/maven/CacheMode.js"; +import SnapshotCache from "../../../lib/ui5Framework/maven/SnapshotCache.js"; const __dirname = import.meta.dirname; const fixturesPath = path.join(__dirname, "..", "..", "fixtures"); @@ -254,7 +254,7 @@ test.serial("graphFromPackageDependencies with inactive workspace file at custom versionOverride: "versionOverride", workspaceName: "default", workspaceConfigPath: path.join(libraryHPath, "custom-ui5-workspace.yaml"), - cacheMode: CacheMode.Force + snapshotCache: SnapshotCache.Force }); t.is(res, "graph"); @@ -278,6 +278,6 @@ test.serial("graphFromPackageDependencies with inactive workspace file at custom t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { versionOverride: "versionOverride", workspace: null, - cacheMode: "Force" + snapshotCache: "Force" }, "enrichProjectGraph got called with correct options"); }); diff --git a/packages/project/test/lib/graph/graph.js b/packages/project/test/lib/graph/graph.js index 4cc4c1386c0..799033a59db 100644 --- a/packages/project/test/lib/graph/graph.js +++ b/packages/project/test/lib/graph/graph.js @@ -2,7 +2,7 @@ import test from "ava"; import path from "node:path"; import sinonGlobal from "sinon"; import esmock from "esmock"; -import CacheMode from "../../../lib/ui5Framework/maven/CacheMode.js"; +import SnapshotCache from "../../../lib/ui5Framework/maven/SnapshotCache.js"; const __dirname = import.meta.dirname; const fixturesPath = path.join(__dirname, "..", "..", "fixtures"); @@ -58,7 +58,7 @@ test.serial("graphFromPackageDependencies", async (t) => { rootConfiguration: "rootConfiguration", rootConfigPath: "/rootConfigPath", versionOverride: "versionOverride", - cacheMode: CacheMode.Off, + snapshotCache: SnapshotCache.Off, workspaceName: null }); @@ -84,7 +84,7 @@ test.serial("graphFromPackageDependencies", async (t) => { t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { versionOverride: "versionOverride", workspace: undefined, - cacheMode: "Off" + snapshotCache: "Off" }, "enrichProjectGraph got called with correct options"); }); @@ -101,7 +101,7 @@ test.serial("graphFromPackageDependencies with workspace name", async (t) => { rootConfigPath: "/rootConfigPath", versionOverride: "versionOverride", workspaceName: "dolphin", - cacheMode: CacheMode.Off + snapshotCache: SnapshotCache.Off }); t.is(res, "graph"); @@ -133,7 +133,7 @@ test.serial("graphFromPackageDependencies with workspace name", async (t) => { t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { versionOverride: "versionOverride", workspace: "workspace", - cacheMode: "Off" + snapshotCache: "Off" }, "enrichProjectGraph got called with correct options"); }); @@ -231,7 +231,7 @@ test.serial("graphFromPackageDependencies with empty workspace", async (t) => { rootConfigPath: "/rootConfigPath", versionOverride: "versionOverride", workspaceName: "dolphin", - cacheMode: CacheMode.Off + snapshotCache: SnapshotCache.Off }); t.is(res, "graph"); @@ -263,7 +263,7 @@ test.serial("graphFromPackageDependencies with empty workspace", async (t) => { t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { versionOverride: "versionOverride", workspace: null, - cacheMode: "Off" + snapshotCache: "Off" }, "enrichProjectGraph got called with correct options"); }); @@ -317,7 +317,7 @@ test.serial("graphFromStaticFile", async (t) => { rootConfiguration: "rootConfiguration", rootConfigPath: "/rootConfigPath", versionOverride: "versionOverride", - cacheMode: CacheMode.Off + snapshotCache: SnapshotCache.Off }); t.is(res, "graph"); @@ -344,7 +344,7 @@ test.serial("graphFromStaticFile", async (t) => { "enrichProjectGraph got called with graph"); t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { versionOverride: "versionOverride", - cacheMode: "Off" + snapshotCache: "Off" }, "enrichProjectGraph got called with correct options"); }); @@ -380,7 +380,7 @@ test.serial("usingObject", async (t) => { rootConfiguration: "rootConfiguration", rootConfigPath: "/rootConfigPath", versionOverride: "versionOverride", - cacheMode: "Off" + snapshotCache: "Off" }); t.is(res, "graph"); @@ -401,7 +401,7 @@ test.serial("usingObject", async (t) => { "enrichProjectGraph got called with graph"); t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { versionOverride: "versionOverride", - cacheMode: "Off" + snapshotCache: "Off" }, "enrichProjectGraph got called with correct options"); }); diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js index ceae8c52e54..b134ac187ac 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.js @@ -5,7 +5,7 @@ import esmock from "esmock"; import DependencyTreeProvider from "../../../../lib/graph/providers/DependencyTree.js"; import projectGraphBuilder from "../../../../lib/graph/projectGraphBuilder.js"; import Specification from "../../../../lib/specifications/Specification.js"; -import CacheMode from "../../../../lib/ui5Framework/maven/CacheMode.js"; +import SnapshotCache from "../../../../lib/ui5Framework/maven/SnapshotCache.js"; const __dirname = import.meta.dirname; @@ -128,7 +128,7 @@ test.serial("enrichProjectGraph", async (t) => { t.is(t.context.Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(t.context.Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: dependencyTree.configuration.framework.version, ui5DataDir: undefined, @@ -239,7 +239,7 @@ test.serial("enrichProjectGraph SNAPSHOT", async (t) => { const projectGraph = await projectGraphBuilder(provider); await ui5Framework.enrichProjectGraph(projectGraph, { - cacheMode: CacheMode.Force + snapshotCache: SnapshotCache.Force }); t.is(getFrameworkLibrariesFromGraphStub.callCount, 1, "getFrameworkLibrariesFromGraph should be called once"); @@ -341,7 +341,7 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => { t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9", ui5DataDir: undefined, @@ -404,7 +404,7 @@ test.serial("enrichProjectGraph: With versionOverride containing snapshot versio t.is(Sapui5MavenSnapshotResolverStub.callCount, 1, "Sapui5MavenSnapshotResolverStub#constructor should be called once"); t.deepEqual(Sapui5MavenSnapshotResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9-SNAPSHOT", ui5DataDir: undefined, @@ -467,7 +467,7 @@ test.serial("enrichProjectGraph: With versionOverride containing latest-snapshot t.is(Sapui5MavenSnapshotResolverStub.callCount, 1, "Sapui5MavenSnapshotResolverStub#constructor should be called once"); t.deepEqual(Sapui5MavenSnapshotResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9-SNAPSHOT", ui5DataDir: undefined, @@ -627,7 +627,7 @@ test.serial("enrichProjectGraph should resolve framework project with version an t.is(getFrameworkLibrariesFromGraphStub.callCount, 1, "getFrameworkLibrariesFromGrap should be called once"); t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.2.3", ui5DataDir: undefined, @@ -732,7 +732,7 @@ test.serial("enrichProjectGraph should resolve framework project " + t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.is(getFrameworkLibrariesFromGraphStub.callCount, 1, "getFrameworkLibrariesFromGraph should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9", ui5DataDir: undefined, @@ -997,7 +997,7 @@ test.serial("enrichProjectGraph should use framework library metadata from works t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.111.1", ui5DataDir: undefined, @@ -1056,7 +1056,7 @@ test.serial("enrichProjectGraph should allow omitting framework version in case t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, ui5DataDir: undefined, version: undefined, @@ -1113,7 +1113,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) t.is(t.context.Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(t.context.Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: dependencyTree.configuration.framework.version, ui5DataDir: expectedUi5DataDir, @@ -1169,7 +1169,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy t.is(t.context.Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(t.context.Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: dependencyTree.configuration.framework.version, ui5DataDir: expectedUi5DataDir, @@ -1225,7 +1225,7 @@ test.serial("enrichProjectGraph should use absolute UI5 data dir from configurat t.is(t.context.Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(t.context.Sapui5ResolverStub.getCall(0).args, [{ - cacheMode: undefined, + snapshotCache: undefined, cwd: dependencyTree.path, version: dependencyTree.configuration.framework.version, ui5DataDir: expectedUi5DataDir, diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 305cdbb04b1..437d29d3287 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -24,7 +24,7 @@ test("check number of exports", (t) => { "ui5Framework/Openui5Resolver", "ui5Framework/Sapui5Resolver", "ui5Framework/Sapui5MavenSnapshotResolver", - "ui5Framework/maven/CacheMode", + "ui5Framework/maven/SnapshotCache", "validation/validator", "validation/ValidationError", "graph/ProjectGraph", diff --git a/packages/project/test/lib/ui5framework/maven/Installer.js b/packages/project/test/lib/ui5framework/maven/Installer.js index 86b00754cdb..c07e9e204bc 100644 --- a/packages/project/test/lib/ui5framework/maven/Installer.js +++ b/packages/project/test/lib/ui5framework/maven/Installer.js @@ -683,7 +683,7 @@ test.serial("_fetchArtifactMetadata: Cache available but disabled", async (t) => cwd: "/cwd/", ui5DataDir: "/ui5Data/", snapshotEndpointUrlCb: () => {}, - cacheMode: "Off" + snapshotCache: "Off" }); sinon.stub(installer, "_synchronize").callsFake( async (pckg, callback) => await callback()); @@ -719,7 +719,7 @@ test.serial("_fetchArtifactMetadata: Cache outdated but enforced", async (t) => cwd: "/cwd/", ui5DataDir: "/ui5Data/", snapshotEndpointUrlCb: () => {}, - cacheMode: "Force" + snapshotCache: "Force" }); sinon.stub(installer, "_synchronize").callsFake( async (pckg, callback) => await callback()); @@ -756,7 +756,7 @@ test.serial("_fetchArtifactMetadata throws", async (t) => { cwd: "/cwd/", ui5DataDir: "/ui5Data/", snapshotEndpointUrlCb: () => {}, - cacheMode: "Force" + snapshotCache: "Force" }); sinon.stub(installer, "_synchronize").callsFake( async (pckg, callback) => await callback()); diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 668318f41c8..c27de5b2f73 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -4,6 +4,7 @@ import MiddlewareManager from "./middleware/MiddlewareManager.js"; import {createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; +import Cache from "@ui5/project/build/cache/Cache"; const log = getLogger("server"); /** @@ -128,6 +129,7 @@ async function _addSsl({app, key, cert}) { * are send for any requested *.html file * @param {boolean} [options.serveCSPReports=false] Enable CSP reports serving for request url * '/.ui5/csp/csp-reports.json' + * @param {string} [options.cache="Default"] Cache mode to use for building UI5 projects. * @param {Function} error Error callback. Will be called when an error occurs outside of request handling. * @returns {Promise} Promise resolving once the server is listening. * It resolves with an object containing the port, @@ -136,7 +138,8 @@ async function _addSsl({app, key, cert}) { */ export async function serve(graph, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, - acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false + acceptRemoteConnections = false, sendSAPTargetCSP = false, + simpleIndex = false, serveCSPReports = false, cache = Cache.Default }, error) { const rootProject = graph.getRoot(); @@ -175,6 +178,7 @@ export async function serve(graph, { const buildServer = await graph.serve({ initialBuildIncludedDependencies, excludedTasks: ["minify", "generateLibraryPreload", "generateComponentPreload", "generateBundle"], + cache, }); const resources = {