/* * This file is part of the xPack project (http://xpack.github.io). * Copyright (c) 2021-2026 Liviu Ionescu. All rights reserved. * * Permission to use, copy, modify, and/or distribute this software * for any purpose is hereby granted, under the terms of the MIT license. * * If a copy of the license was not distributed with this file, it can * be obtained from https://opensource.org/license/mit. */ // ---------------------------------------------------------------------------- import assert from 'node:assert' import * as path from 'node:path' import * as os from 'node:os' import { Logger } from '@xpack/logger' // ---------------------------------------------------------------------------- import { LiquidEngine } from './liquid-engine.js' import { LiquidSubstitutionsVariables, LiquidSubstitutionsStrings, } from '../data/substitutions-variables.js' import { filterPath } from '../functions/filter-paths.js' import { isJsonObject, isString } from '../functions/is-something.js' import { getErrorMessage, hasLiquidSyntax } from '../functions/utils.js' import { performSubstitutions } from '../functions/perform-substitutions.js' import { JsonBuildConfigurations, JsonBuildConfigurationTemplate, JsonBuildConfiguration, JsonBuildConfigurationContent, JsonDependencies, JsonBuildConfigurationInherits, } from '../types/json.js' import { Actions, Action } from './actions.js' import { buildFolderRelativePathPropertyName } from './data-model.js' import { ConfigurationError } from './errors.js' import { TemplateExpander } from './template-expander.js' // ============================================================================ /** * Configuration parameters for constructing a build configurations collection. * * @remarks * This interface defines the required configuration for creating an * instance of {@link BuildConfigurations}. Most properties are mandatory * except for the optional jsonBuildConfigurations, which can * be undefined if there are no build configurations defined in the package. * * The parameters provide the collection with access to the Liquid templating * engine, substitution variables hierarchy, build configuration definitions * from the package manifest, and the logger for diagnostic output during * configuration processing. */ export interface BuildConfigurationsConstructorParameters { /** * The Liquid templating engine for variable substitution. */ engine: LiquidEngine /** * The variables available for substitution in configuration definitions. */ substitutionsVariables: LiquidSubstitutionsVariables /** * The JSON build configurations definitions, or undefined if no build * configurations are defined. */ jsonBuildConfigurations: JsonBuildConfigurations | undefined /** * The logger instance for output and diagnostics. */ log: Logger } /** * A collection of xpm build configurations. * * @remarks * This class manages build configurations defined in package metadata, * including template expansion with matrix parameters and initialisation of * derived configuration instances. * * Configuration lifecycle phases: * *
    *
  1. Construction: Basic setup without processing configurations.
  2. *
  3. Initialisation: Template name expansion without content * evaluation.
  4. *
  5. Retrieval: On-demand instantiation when accessed via * get().
  6. *
  7. Configuration Initialisation: Full processing including * inheritance, property resolution, dependency substitution, and * action preparation.
  8. *
* * This lazy evaluation strategy ensures that only configurations actually * used incur the cost of template evaluation, inheritance resolution, and * variable substitution. */ export class BuildConfigurations { // -------------------------------------------------------------------------- // Public Members. /** * The logger instance for output and diagnostics. * * @remarks * This logger provides trace-level diagnostics throughout the build * configuration lifecycle, including template expansion, inheritance * resolution, property merging, and dependency substitution. It enables * detailed debugging of complex build configuration hierarchies without * impacting runtime performance when tracing is disabled. */ readonly log: Logger /** * The Liquid templating engine for variable substitution. * * @remarks * This engine instance is shared across all build configurations and * configured with custom filters for platform detection, path * manipulation, and xpm-specific operations. It processes templates in * configuration names, matrix parameters, properties, dependencies, and * actions, ensuring consistent template evaluation throughout the * configuration lifecycle. */ readonly engine: LiquidEngine /** * The variables available for substitution in configuration definitions. * * @remarks * This comprehensive variable hierarchy provides the base context for all * build configuration template evaluation, extended per-configuration with * specific properties, dependencies, and matrix parameters. * * Base hierarchy includes: * *
    *
  1. Environment variables: env namespace with system * environment.
  2. *
  3. Platform detection: os namespace with * platform-specific values.
  4. *
  5. Path utilities: path namespace with path * manipulationfunctions.
  6. *
  7. Package metadata: package namespace with * name, version, dependencies.
  8. *
* * Individual configurations extend this with their own `properties`, * `configuration`, and `matrix` namespaces during initialisation. */ readonly substitutionsVariables: LiquidSubstitutionsVariables /** * The JSON object containing build configuration definitions. * * @remarks * This object holds raw build configuration definitions from the * `package.json` `xpack.buildConfigurations` section. Configurations can be: * *
    *
  1. Regular configurations: Direct objects with properties, * dependencies, actions, and inheritance.
  2. *
  3. Template configurations: Objects with matrix * and template * properties for generating multiple configurations from a single * definition.
  4. *
* * Template configuration names (containing `{{` markers) trigger matrix * expansion during initialisation, creating concrete configurations from * the Cartesian product of matrix parameter values. Each configuration * can inherit from others, creating complex dependency hierarchies. */ readonly jsonBuildConfigurations: JsonBuildConfigurations // -------------------------------------------------------------------------- // Protected Members. /** * Map of build configuration names to their corresponding instances. * * @remarks * This map serves as the primary configuration registry, populated during * collection initialisation with entries for all discovered configurations. * * Key characteristics: * *
    *
  1. Known only after BuildConfigurations.initialise() * completes.
  2. *
  3. Possibly empty if there are no build configurations defined.
  4. *
  5. Values can be undefined to indicate a configuration * exists but hasn't been instantiated yet (lazy loading).
  6. *
  7. For template configurations, contains one entry per expanded * combination, not the original template definition.
  8. *
* * Configurations transition from `undefined` to instantiated when first * accessed via {@link BuildConfigurations.get}, implementing the * lazy evaluation pattern to avoid unnecessary processing. */ protected readonly _buildConfigurationsMap: Map< string, BuildConfiguration | undefined > = new Map() /** * Map of expanded build configuration names to their JSON source names. * * @remarks * This reverse mapping enables retrieving the original configuration * definition from `jsonBuildConfigurations` when lazy-loading * configuration instances. * * Mapping behavior: * *
    *
  1. For regular configurations: Maps configuration name to itself * (identity mapping).
  2. *
  3. For template configurations: Maps each generated configuration * name * back to the original template name (e.g., release-x64 → * release-\{\{ matrix.arch \}\}).
  4. *
  5. Known only after BuildConfigurations.initialise() * completes.
  6. *
  7. Enables BuildConfigurations.get() to locate the * correct JSON definition when instantiating a configuration on * demand.
  8. *
* * This indirection is essential for lazy evaluation, allowing deferred * instantiation while maintaining the connection to original definitions. */ protected readonly _jsonBuildConfigurationsNamesMap: Map = new Map() /** * Set of all build configuration names for duplicate detection. * * @remarks * This set provides O(1) existence checks for configuration names, * enabling efficient validation during template expansion to prevent * duplicate configurations. * * Duplicate scenarios detected: * *
    *
  1. Explicit duplicates in package.json with identical * names.
  2. *
  3. Template expansion conflicts where different templates generate the * same concrete configuration name.
  4. *
  5. Conflicts between template-generated names and explicitly defined * configuration names.
  6. *
* * Detection occurs during {@link BuildConfigurations.initialise}, * throwing {@link ConfigurationError} when duplicates are found to ensure * configuration name uniqueness. */ protected readonly _namesSet: Set = new Set() /** * Flag indicating whether the collection has been initialised. * * @remarks * This flag prevents redundant initialisation and ensures idempotent * behavior when {@link BuildConfigurations.initialise} is called * multiple times. * * State transitions: * *
    *
  1. Initially false after construction.
  2. *
  3. Set to true after successful template expansion * and configuration * name registration.
  4. *
  5. Checked at the beginning of * BuildConfigurations.initialise() to return early if * already initialised.
  6. *
* * This pattern supports safe repeated calls during complex initialisation * sequences without duplicating work or corrupting internal state. */ protected _isInitialised = false /** * Cached array of all build configuration names in the collection. * * @remarks * This array provides O(1) access to configuration names without * repeatedly creating new arrays from the map keys, improving performance * when the names are accessed multiple times. * * Key characteristics: * *
    *
  1. Empty initially after construction.
  2. *
  3. Populated during * BuildConfigurations.initialise() after all * configuration names are determined.
  4. *
  5. Contains all configuration names including those generated from * templates.
  6. *
  7. Returned by the names getter for efficient repeated * access.
  8. *
* * This cached approach avoids the overhead of calling * `Array.from(map.keys())` on every access whilst still * providing a clean getter interface. */ protected _names: string[] = [] // -------------------------------------------------------------------------- // Constructor and async initialiser. /** * Constructs a build configurations collection. * * @remarks * The constructor performs partial initialisation. Complete * initialisation requires calling * {@link BuildConfigurations.initialise}. * * @param engine - The Liquid templating engine for variable substitution. * @param substitutionsVariables - The variables available for substitution. * @param jsonBuildConfigurations - The JSON build configurations definitions, * or undefined if no build configurations are defined. * @param log - The logger instance for output and diagnostics. */ constructor({ engine, substitutionsVariables, jsonBuildConfigurations, log, }: BuildConfigurationsConstructorParameters) { assert(log, 'log is required') assert(engine, 'engine is required') assert(substitutionsVariables, 'substitutionsVariables is required') log.trace(`${BuildConfigurations.name}()`) this.log = log this.engine = engine this.substitutionsVariables = substitutionsVariables this.jsonBuildConfigurations = jsonBuildConfigurations ?? {} // log.trace('substitutionsVariables => ', this.substitutionsVariables) } /** * Completes the async initialisation of the build configurations collection. * * @remarks * This method implements the first step of lazy evaluation. It processes * all build configuration definitions by expanding template configuration * names based on matrix parameters, but does not evaluate the configuration * content or perform Liquid substitutions. The actual template evaluation * and variable substitution occur later when individual configurations are * initialised via {@link BuildConfiguration.initialise}, and only * for configurations that are actually used. This approach avoids unnecessary * operations on unused configurations. * * Processing steps: * *
    *
  1. Return early if already initialised (idempotent behaviour).
  2. *
  3. Iterate through all build configuration definitions from the JSON * object.
  4. *
  5. For template configurations (names containing \{\{): *
      *
    • Call _processTemplate() to expand and register all * generated configurations.
    • *
    *
  6. *
  7. For regular configurations: *
      *
    • Validate uniqueness of the configuration name.
    • *
    • Register the configuration in internal maps with * undefined value (lazy loading).
    • *
    *
  8. *
  9. Cache the array of all configuration names for efficient repeated * access.
  10. *
* * @returns A promise that resolves to `true` if initialisation was performed, * or `false` if already initialised. * * @throws {@link ConfigurationError} * If duplicate names are detected or template expansion fails. */ async initialise(): Promise { const log = this.log if (this._isInitialised) { log.trace(`${BuildConfigurations.name}.initialise() again`) return false } log.trace(`${BuildConfigurations.name}.initialise()`) for (const [ buildConfigurationName, jsonBuildConfiguration, ] of Object.entries(this.jsonBuildConfigurations)) { if (hasLiquidSyntax(buildConfigurationName)) { await this._processTemplate({ buildConfigurationName, jsonBuildConfigurationTemplate: jsonBuildConfiguration as JsonBuildConfigurationTemplate, }) } else { if (this._namesSet.has(buildConfigurationName)) { throw new ConfigurationError( `build configuration name ` + `"${buildConfigurationName}" already defined` ) } else { this._buildConfigurationsMap.set(buildConfigurationName, undefined) this._jsonBuildConfigurationsNamesMap.set( buildConfigurationName, buildConfigurationName ) this._namesSet.add(buildConfigurationName) } } } const names = Array.from(this._buildConfigurationsMap.keys()) this._names = names log.trace(`${BuildConfigurations.name}.initialise() =>`, names) this._isInitialised = true return true } // -------------------------------------------------------------------------- // Public Methods. /** * The number of build configurations in the collection. * * @remarks * This value is known only after `initialise()`. * * This getter provides direct access to the collection size, enabling * callers to check for emptiness or iterate with knowledge of the * collection's extent. * * @returns The number of build configurations in the collection. */ get size(): number { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing size' ) return this._buildConfigurationsMap.size } /** * Indicates whether the collection is empty. * * @remarks * This value is known only after `initialise()`. * * @returns `true` if there are no build configurations, `false` otherwise. */ get isEmpty(): boolean { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing isEmpty' ) return this._buildConfigurationsMap.size === 0 } /** * The names of all build configurations. * * @remarks * This value is known only after `initialise()`. * * This getter returns the cached array of configuration names for * efficient repeated access without recreating the array. * * @returns An array of build configuration names. */ get names(): string[] { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing names' ) return this._names } /** * Retrieves the JSON configuration name for a build configuration. * * @param buildConfigurationName - The build configuration name to resolve. * @returns The JSON configuration name associated with the given build * configuration name. * * @remarks * For template-generated configurations, this returns the template * name. * * @throws {@link InputError} * If the build configuration does not exist. */ getJsonName(buildConfigurationName: string): string { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing getJsonName()' ) const name = this._jsonBuildConfigurationsNamesMap.get( buildConfigurationName ) if (name === undefined) { throw new ConfigurationError( `build configuration "${buildConfigurationName}" does not exist` ) } return name } /** * Determines whether a JSON definition exists for a build configuration. * * @param buildConfigurationName - The build configuration name to check. * @returns `true` if a JSON definition exists, `false` otherwise. */ hasJson(buildConfigurationName: string): boolean { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing hasJson()' ) return this._jsonBuildConfigurationsNamesMap.has(buildConfigurationName) } /** * Retrieves the JSON build configuration definition. * * @param buildConfigurationName - The build configuration name to resolve. * @returns The JSON build configuration definition. */ getJson(buildConfigurationName: string): JsonBuildConfiguration { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing getJson()' ) return this.jsonBuildConfigurations[ this.getJsonName(buildConfigurationName) ] } /** * Determines whether a build configuration is hidden. * * @param buildConfigurationName - The build configuration name to check. * @returns `true` if the configuration is hidden, `false` otherwise. */ isHidden(buildConfigurationName: string): boolean { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing isHidden()' ) const jsonBuildConfigurationName = this.getJsonName(buildConfigurationName) if (jsonBuildConfigurationName.includes('{{')) { const jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate = this.jsonBuildConfigurations[ jsonBuildConfigurationName ] as JsonBuildConfigurationTemplate return jsonBuildConfigurationTemplate.template.hidden ?? false } const jsonBuildConfigurationContent: JsonBuildConfigurationContent = this .jsonBuildConfigurations[ jsonBuildConfigurationName ] as JsonBuildConfigurationContent return jsonBuildConfigurationContent.hidden ?? false } /** * Determines whether a build configuration exists in the collection. * * @param buildConfigurationName - The build configuration name to check. * @returns `true` if the configuration exists, `false` otherwise. */ has(buildConfigurationName: string): boolean { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing has()' ) return this._buildConfigurationsMap.has(buildConfigurationName) } /** * Retrieves a build configuration by name, creating it if required. * * @remarks * This method implements lazy evaluation to avoid unnecessary * operations. Build configurations are instantiated on demand but * remain uninitialised until actually used. * * Retrieval process: * *
    *
  1. Check if the configuration already exists in the internal map.
  2. *
  3. If found and already instantiated, return the existing instance.
  4. *
  5. If the configuration name is unknown (not in JSON name mapping), * throw InputError.
  6. *
  7. For known but not yet instantiated configurations: *
      *
    • Resolve the original JSON configuration name (handles both * regular and template-generated configurations).
    • *
    • Retrieve the JSON configuration definition.
    • *
    • Create a new BuildConfiguration instance.
    • *
    • Store the instance in the map for future access.
    • *
    *
  8. *
  9. Return the configuration instance (still uninitialised).
  10. *
* * The two-step lazy evaluation process: * *
    *
  1. During collection initialisation * (BuildConfigurations.initialise()), only the * matrix of options is evaluated for each template, expanding * configuration names without processing their content.
  2. *
  3. Later, when a configuration is accessed via this method and * subsequently initialised * (BuildConfiguration.initialise()), the template * is fully evaluated and Liquid substitutions are performed on * all properties.
  4. *
* * This approach ensures that only build configurations that are * actually used incur the cost of template evaluation and variable * substitution. * * @param buildConfigurationName - The build configuration name to retrieve. * @returns The build configuration instance. * * @throws {@link InputError} * If a configuration with the specified name does not exist. */ get(buildConfigurationName: string): BuildConfiguration { assert( this._isInitialised, 'BuildConfigurations collection must be initialised before ' + 'accessing get()' ) const log = this.log log.trace(`${BuildConfigurations.name}.get(${buildConfigurationName})`) let buildConfiguration = this._buildConfigurationsMap.get( buildConfigurationName ) if (buildConfiguration === undefined) { // This will throw InputError if the configuration doesn't exist const jsonBuildConfigurationName: string = this.getJsonName( buildConfigurationName ) // Safety net: This fallback to empty object is defensive programming. // The jsonBuildConfigurations[jsonBuildConfigurationName] should always // be defined because getJsonName() throws if the configuration doesn't // exist. The ?? {} provides protection against unexpected inconsistencies // between the names map and the configurations object. /* c8 ignore start - safety net, they are always defined */ const jsonBuildConfiguration: JsonBuildConfigurationContent = (this .jsonBuildConfigurations[jsonBuildConfigurationName] ?? {}) as JsonBuildConfigurationContent /* c8 ignore stop */ buildConfiguration = new BuildConfiguration({ buildConfigurationName, jsonBuildConfiguration, parentBuildConfigurations: this, }) this._buildConfigurationsMap.set( buildConfigurationName, buildConfiguration ) } // await buildConfiguration.initialise() return buildConfiguration } // -------------------------------------------------------------------------- // Private Methods. /** * Processes a template build configuration by expanding it and registering * the generated configurations. * * @remarks * This helper method is called during collection initialisation for each * build configuration whose name contains template syntax * (\{\{ markers). * * Processing steps: * *
    *
  1. Calls _expandTemplateBuildConfigurations() to generate * all configuration instances from the template's matrix parameters.
  2. *
  3. Validates that each expanded configuration name is unique and does * not conflict with existing configurations.
  4. *
  5. Registers each expanded configuration in the internal maps: *
      *
    • _buildConfigurationsMap: Maps name to configuration * instance.
    • *
    • _jsonBuildConfigurationsNamesMap: Maps expanded name * back to original template name.
    • *
    • _namesSet: Tracks all registered * names for duplicate detection.
    • *
    *
  6. *
* * @param buildConfigurationName - The template configuration name * containing Liquid variables. * @param jsonBuildConfiguration - The JSON template definition containing * matrix parameters and a configuration template. * @returns A promise that resolves when processing is complete. * * @throws {@link ConfigurationError} * If duplicate configuration names are detected during expansion or if * template expansion fails. */ protected async _processTemplate({ buildConfigurationName, jsonBuildConfigurationTemplate, }: { buildConfigurationName: string jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate }): Promise { // Expand templates and generate multiple build configurations. try { const expandedBuildConfigurationsMap = await this._expandTemplateBuildConfigurations({ buildConfigurationName, jsonBuildConfigurationTemplate, }) for (const [ expandedBuildConfigurationName, expandedBuildConfiguration, ] of expandedBuildConfigurationsMap) { if (this._namesSet.has(expandedBuildConfigurationName)) { throw new ConfigurationError( `duplicate build configuration name ` + `"${expandedBuildConfigurationName}" ` + `could not be generated from template.` ) } else { this._buildConfigurationsMap.set( expandedBuildConfigurationName, expandedBuildConfiguration ) this._jsonBuildConfigurationsNamesMap.set( expandedBuildConfigurationName, buildConfigurationName ) this._namesSet.add(expandedBuildConfigurationName) } } } catch (error) { const message = getErrorMessage(error) + ` in buildConfiguration "${buildConfigurationName}"` throw new ConfigurationError(message) } } /** * Expands a template build configuration into multiple configurations. * * @remarks * This method uses the {@link TemplateExpander} to compute the Cartesian * product of matrix parameter values and creates a configuration for each * combination, substituting matrix values into both the configuration name * and content. * * Processing steps: * *
    *
  1. Validates matrix and template structure.
  2. *
  3. Delegates to TemplateExpander for matrix processing and * name expansion.
  4. *
  5. Creates configuration instances via factory callback for each * combination.
  6. *
* * Matrix variables are scoped to individual configurations and accessible * via the `matrix` namespace during property, dependency, and action * evaluation. * * @param buildConfigurationName - The template configuration name containing * Liquid variables. * @param jsonBuildConfigurationTemplate - The template definition containing * matrix parameters and a configuration template. * @returns A promise that resolves to a map of expanded configuration names * to their corresponding instances. * * @throws {@link ConfigurationError} * If the matrix structure is invalid or substitution fails. */ protected async _expandTemplateBuildConfigurations({ buildConfigurationName, jsonBuildConfigurationTemplate, }: { buildConfigurationName: string jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate }): Promise> { const log = this.log log.trace( `${BuildConfigurations.name}.` + `#expandTemplateBuildConfigurations(${buildConfigurationName})` ) // Validate template structure if (!isJsonObject(jsonBuildConfigurationTemplate.matrix)) { throw new ConfigurationError( `buildConfiguration "${buildConfigurationName}" ` + `matrix is not an object` ) } if (!isJsonObject(jsonBuildConfigurationTemplate.template)) { throw new ConfigurationError( `buildConfiguration "${buildConfigurationName}" ` + `template is not a JSON object` ) } // Use TemplateExpander for matrix processing and expansion const expander = new TemplateExpander< JsonBuildConfigurationContent, BuildConfiguration >({ engine: this.engine, substitutionsVariables: this.substitutionsVariables, log: this.log, }) return await expander.expandTemplate({ templateName: buildConfigurationName, matrix: jsonBuildConfigurationTemplate.matrix, templateContent: jsonBuildConfigurationTemplate.template, templateType: 'buildConfiguration', instanceFactory: ( expandedName: string, combination: Record, templateContent: JsonBuildConfigurationContent, originalTemplateName: string ) => new BuildConfiguration({ buildConfigurationName: expandedName, templateBuildConfigurationName: originalTemplateName, jsonBuildConfiguration: templateContent, parentBuildConfigurations: this, matrixParameters: { ...combination }, }), }) } } // ============================================================================ /** * Configuration parameters for constructing a build configuration instance. * * @remarks * This interface defines the required configuration for creating an * instance of {@link BuildConfiguration}. Most properties are mandatory * except for the optional templateBuildConfigurationName and * matrixParameters, which are only needed for template-generated * configurations created from matrix expansion. * * The parameters provide the configuration with its identity (name, * optional template name), the JSON configuration definition, access to * the parent collection for shared resources, and optional matrix parameter * values for template-generated configurations. */ export interface BuildConfigurationConstructorParameters { /** * The configuration name after substitution. */ buildConfigurationName: string /** * The template configuration name, if derived from a template. */ templateBuildConfigurationName?: string /** * The JSON configuration definition. */ jsonBuildConfiguration: JsonBuildConfigurationContent /** * The parent configurations collection. */ parentBuildConfigurations: BuildConfigurations /** * Optional matrix parameter values for template-generated configurations. */ matrixParameters?: LiquidSubstitutionsStrings } /** * An individual xpm build configuration. * * @remarks * Build configurations are initialised lazily and may inherit * properties, dependencies, and actions from other configurations. * * A configuration can exist in three states: * *
    *
  1. Undefined: Name is known but instance not yet created.
  2. *
  3. Instantiated: Object exists but not yet fully processed.
  4. *
  5. Initialised: Inheritance resolved, properties evaluated, * dependencies substituted, and actions prepared.
  6. *
* * Inheritance is processed recursively with circular reference detection. * Later inherited properties override earlier ones, and local properties * override all inherited ones. Dependencies and actions are merged from * all inherited configurations. */ export class BuildConfiguration { // -------------------------------------------------------------------------- // Public Members. /** * The build configuration name after substitution. * * @remarks * This is the final, expanded configuration name used for identification * and selection. For template-generated configurations, this is the * concrete name after matrix substitution (e.g., `release-x64` rather than * `release-{{ matrix.arch }}`). * * The name is used for: * *
    *
  1. User-facing identification when listing or selecting * configurations.
  2. *
  3. Build folder path generation (default: * build/\{name\}).
  4. *
  5. Logging and diagnostic output to track configuration lifecycle.
  6. *
  7. Inheritance references from other configurations.
  8. *
* * Names must be unique within the configurations collection, enforced * during {@link BuildConfigurations.initialise}. */ readonly name: string /** * The template build configuration name, if derived from a template. * * @remarks * For template-generated configurations, this preserves the original * template name containing Liquid variables (e.g., * `release-{{ matrix.arch }}`), while `buildConfigurationName` holds the * expanded concrete name. * * Usage: * *
    *
  1. Undefined for regular (non-template) configurations.
  2. *
  3. Set to the template name for configurations generated from matrix * expansion.
  4. *
  5. Used to determine whether full JSON substitution is needed during * initialisation (templates require complete substitution, regular * configurations only substitute specific fields).
  6. *
  7. Enables tracing and debugging of template expansion process.
  8. *
*/ readonly templateName?: string /** * The parent build configurations collection. * * @remarks * This reference maintains the hierarchical relationship between * individual configurations and their containing collection, providing * essential context for configuration initialisation. * * The parent collection provides access to: * *
    *
  1. Liquid templating engine for variable substitution.
  2. *
  3. Base substitution variables hierarchy (package metadata, * environment, platform detection).
  4. *
  5. Logger instance for diagnostic output.
  6. *
  7. JSON build configurations lookup for inheritance resolution.
  8. *
  9. Other configuration instances when processing inheritance chains.
  10. *
* * This design enables configurations to access shared resources without * duplicating them, while supporting complex inheritance relationships * where configurations reference and inherit from each other. */ readonly parentBuildConfigurations: BuildConfigurations /** * The list of inherited configuration names. * * @remarks * This array specifies the inheritance chain for this configuration, * processed sequentially during initialisation with later entries * overriding earlier ones. * * Inheritance processing: * *
    *
  1. Populated from inherits or deprecated * inherit field during * initialisation.
  2. *
  3. Supports both string (single parent) and array (multiple parents) * formats.
  4. *
  5. Each inherited configuration is initialised recursively before * merging its properties, dependencies, and actions.
  6. *
  7. Circular references are detected and rejected with * InputError.
  8. *
  9. Later inherited configurations override properties from earlier * ones, and local properties override all inherited ones.
  10. *
*/ inheritsNames: string[] = [] /** * Indicates whether the configuration is hidden. * * @remarks * Hidden configurations are used for inheritance bases or intermediate * configurations that shouldn't be directly selected for building. * * Effects of hidden status: * *
    *
  1. Hidden configurations don't compute build folder relative paths * during initialisation (optimization for inheritance-only configs).
  2. *
  3. May be excluded from user-facing configuration lists depending on * application logic.
  4. *
  5. Still fully initialised and available for inheritance by other * configurations.
  6. *
  7. Derived from hidden field in JSON configuration definition * (defaults to false).
  8. *
* * Common use case: * * Base configurations that define common properties, * dependencies, or actions inherited by multiple concrete configurations. */ readonly isHidden: boolean /** * The resolved properties for this configuration. * * @remarks * This object contains the final merged properties after inheritance * resolution and becomes available in the `properties` namespace for * Liquid template substitution. * * Property resolution order: * *
    *
  1. Start with empty object.
  2. *
  3. Merge properties from each inherited configuration in sequence * (later overrides earlier).
  4. *
  5. Merge local properties from JSON definition (overrides all * inherited).
  6. *
  7. Add computed buildFolderRelativePath property * for non-hidden * configurations.
  8. *
* * Properties are accessible in templates as `{{ properties.key }}` and * commonly used for compiler flags, toolchain paths, optimization * settings, and build-specific configuration values. */ properties: LiquidSubstitutionsStrings = {} /** * The resolved dependencies after substitutions. * * @remarks * This object contains the final merged dependencies after inheritance * resolution and Liquid template substitution. * * Dependency resolution workflow: * *
    *
  1. Start with empty object.
  2. *
  3. Merge dependencies from each inherited configuration * in sequence * (later overrides earlier).
  4. *
  5. Merge local dependencies from JSON definition.
  6. *
  7. Perform Liquid template substitution on the entire * dependencies * object with full configuration context (properties, matrix, etc.).
  8. *
* * This enables configuration-specific dependencies with dynamic version * ranges or package selection based on matrix parameters, platform * detection, or configuration properties. */ dependencies: JsonDependencies = {} /** * The resolved development dependencies after substitutions. * * @remarks * This object contains the final merged development dependencies after * inheritance resolution and Liquid template substitution. * * Resolution workflow mirrors `dependencies`: * *
    *
  1. Start with empty object.
  2. *
  3. Merge devDependencies from each inherited configuration * in sequence * (later overrides earlier).
  4. *
  5. Merge local devDependencies from JSON definition.
  6. *
  7. Perform Liquid template substitution on the entire * devDependencies * object with full configuration context.
  8. *
* * Typical use: Test frameworks, build tools, or debugging utilities * specific to certain configurations (e.g., debug builds might include * additional analysis tools). */ devDependencies: JsonDependencies = {} /** * The JSON build configuration content from package metadata. * * @remarks * This holds the raw configuration definition as it appears in * `package.json`, before inheritance resolution and variable substitution. * * The definition is preserved to: * *
    *
  1. Enable external modification (e.g., xpm uninstall * updates this * directly).
  2. *
  3. Support deferred template evaluation during * BuildConfiguration.initialise().
  4. *
  5. Provide the source for inheritance when other configurations * reference this one.
  6. *
  7. Allow re-evaluation with different variable contexts if needed.
  8. *
* * This immutable storage ensures configurations can be safely referenced * during inheritance resolution without side effects. */ jsonBuildConfiguration: JsonBuildConfigurationContent /** * Indicates whether this configuration originates from a template. * * @remarks * This flag determines the substitution strategy during configuration * initialisation, with template configurations requiring more extensive * processing. * * Template vs regular configuration processing: * *
    *
  1. Template configurations (isTemplate === true): *
      *
    • Entire JSON configuration is stringified and substituted.
    • *
    • Matrix parameters available throughout all fields.
    • *
    • More expensive but supports matrix references anywhere.
    • *
    *
  2. *
  3. Regular configurations (isTemplate === false): *
      *
    • Only inherits field is substituted initially.
    • *
    • Other fields processed selectively during inheritance * resolution.
    • *
    • More efficient for configurations without matrix parameters.
    • *
    *
  4. *
* * Set to `true` when `templateBuildConfigurationName` is defined, * indicating the configuration was generated from a template expansion. */ isTemplate: boolean // -------------------------------------------------------------------------- // Protected Members. /** * The logger instance for output and diagnostics. * * @remarks * This logger provides trace-level diagnostics throughout the build * configuration lifecycle, including template substitution, inheritance * resolution, property merging, dependency substitution, and action * preparation. * * It is initialised in the constructor from the parent collection's logger * and used consistently across all helper methods to maintain coherent * logging output. This enables detailed debugging of complex configuration * hierarchies without impacting runtime performance when tracing is * disabled. */ protected readonly _log: Logger /** * The variables used for substitution in this configuration. * * @remarks * This extended variable hierarchy combines the base collection variables * with configuration-specific context, enabling accurate template * evaluation. * * Extension hierarchy: * *
    *
  1. Starts with parent collection's base variables (env, os, path, * package).
  2. *
  3. Extended with properties: Merged from inheritance * chain and local * properties.
  4. *
  5. Extended with matrix: Parameter values for * template-generated * configurations.
  6. *
  7. Extended with configuration: The configuration * object itself * (name, dependencies, properties) accessible for self-reference.
  8. *
* * This complete context is used for all substitutions within the * configuration: properties, dependencies, devDependencies, and actions. */ protected _substitutionsVariables: LiquidSubstitutionsVariables /** * The matrix parameter values for template-generated configurations. * * @remarks * For template-generated configurations, this object contains the specific * matrix parameter values that produced this configuration instance from * the template. * * Usage pattern: * *
    *
  1. Undefined for regular (non-template) configurations.
  2. *
  3. For template configurations, contains key-value pairs from the matrix * combination (e.g., * \{ arch: 'x64', optimize: 'speed' \}).
  4. *
  5. Merged into substitution variables during initialisation, making * values accessible via the matrix namespace throughout the * configuration.
  6. *
  7. Used in configuration name substitution, property values, * dependencies, and action commands.
  8. *
* * Example: A template `release-{{ matrix.arch }}` with matrix parameters * `{ arch: 'x64' }` becomes the concrete configuration `release-x64`. */ protected readonly matrixParameters?: LiquidSubstitutionsStrings /** * The actions associated with this build configuration. * * @remarks * This actions collection is created during configuration initialisation * and combines inherited actions with local action definitions. * * Action assembly workflow: * *
    *
  1. Undefined until BuildConfiguration.initialise() is * called.
  2. *
  3. Collect actions from all inherited configurations in the inheritance * chain.
  4. *
  5. Create new Actions collection with inherited * actions map and local action definitions.
  6. *
  7. Actions inherit the configuration's substitution variables context, * including properties and matrix parameters.
  8. *
* * Actions are accessible after configuration initialisation but remain * themselves uninitialised until retrieved and initialised individually, * maintaining the lazy evaluation pattern. */ protected _actions: Actions | undefined /** * The resolved build folder relative path. * * @remarks * This path specifies where build outputs for this configuration should be * placed, computed during initialisation and added back to properties for * use in subsequent substitutions. * * Computation workflow: * *
    *
  1. Undefined until BuildConfiguration.initialise() is * called.
  2. *
  3. Not computed for hidden configurations (optimization).
  4. *
  5. If buildFolderRelativePath property exists, perform Liquid * substitution with full configuration context.
  6. *
  7. Otherwise, generate default path: * build/\{sanitized-config-name\}.
  8. *
  9. Added to properties.buildFolderRelativePath for use * in action * commands and dependency references.
  10. *
* * The path is relative to the package root and used by build tools to * organize outputs from different configurations. */ protected _buildFolderRelativePath?: string /** * Set of inherited configuration names for circular reference detection. * * @remarks * This set tracks the inheritance chain being processed to detect and * prevent circular inheritance references. * * Detection mechanism: * *
    *
  1. Initially empty when configuration initialisation begins.
  2. *
  3. Each inherited configuration name is added before processing that * configuration's inheritance.
  4. *
  5. If a configuration attempts to inherit from a name already in the * set, a circular reference exists.
  6. *
  7. Circular references trigger InputError with details * about the problematic inheritance chain.
  8. *
* * Example: If config A inherits from B, B from C, and C from A, the * circular dependency is detected when C attempts to inherit from A. */ protected _inheritedNamesSet: Set = new Set() /** * Flag indicating whether the configuration has been initialised. * * @remarks * This flag ensures idempotent initialization and prevents redundant * processing when {@link BuildConfiguration.initialise} is called * multiple times. * * State transitions: * *
    *
  1. Initially false after construction.
  2. *
  3. Set to true after successful inheritance resolution, * property * merging, dependency substitution, and action preparation.
  4. *
  5. Checked at the start of * BuildConfiguration.initialise() to return early if * already initialised.
  6. *
* * This pattern is critical for inheritance processing, as configurations * may be initialised multiple times when referenced by multiple children, * but should only process their inheritance chain once. */ protected _isInitialised = false // -------------------------------------------------------------------------- // Constructor and async initialiser. /** * Constructs a build configuration instance. * * @param buildConfigurationName - The configuration name after substitution. * @param templateBuildConfigurationName - The template configuration name, if * derived from a template. * @param jsonBuildConfiguration - The JSON configuration definition. * @param parentBuildConfigurations - The parent configurations collection. * @param matrixParameters - Optional matrix parameter values for * template-generated configurations. * * @remarks * The constructor performs partial initialisation. Full initialisation * requires calling {@link BuildConfiguration.initialise}. */ constructor({ buildConfigurationName, templateBuildConfigurationName, jsonBuildConfiguration, parentBuildConfigurations, matrixParameters, }: BuildConfigurationConstructorParameters) { assert(buildConfigurationName, 'buildConfigurationName is required') assert(jsonBuildConfiguration, 'jsonBuildConfiguration is required') assert(parentBuildConfigurations, 'parentBuildConfigurations is required') const log = parentBuildConfigurations.log this._log = log log.trace(`${BuildConfiguration.name}(${buildConfigurationName})`) this.name = buildConfigurationName this.jsonBuildConfiguration = jsonBuildConfiguration this.parentBuildConfigurations = parentBuildConfigurations if (matrixParameters !== undefined) { this.matrixParameters = matrixParameters } if (templateBuildConfigurationName !== undefined) { this.templateName = templateBuildConfigurationName } this._substitutionsVariables = { ...this.parentBuildConfigurations.substitutionsVariables, } this.isHidden = this.jsonBuildConfiguration.hidden ?? false this.isTemplate = this.templateName !== undefined // The rest of the initialisation is done in the async initialiser. } /** * Completes the async initialisation of the build configuration. * * @remarks * This method resolves inheritance, applies variable substitutions, * computes dependencies, and prepares actions. * * Initialisation workflow: * *
    *
  1. Return early if already initialised (idempotent behaviour).
  2. *
  3. For template configurations: Call * _substituteTemplate() to substitute * matrix parameters throughout the entire JSON structure.
  4. *
  5. For non-template configurations: Call * _substituteInherits() to substitute * only the inherits field.
  6. *
  7. Call _processInherits() to: *
      *
    • Process inheritance chain recursively with circular reference * detection.
    • *
    • Merge properties, dependencies, and devDependencies from inherited * configurations (later overrides earlier).
    • *
    • Collect inherited actions into a map.
    • *
    *
  8. *
  9. Apply local properties and update substitution variables context.
  10. *
  11. For visible configurations: Compute build folder relative path via * _getBuildFolderRelativePath().
  12. *
  13. Substitute Liquid templates in dependencies and devDependencies.
  14. *
  15. Create actions collection with inherited actions and local * actions.
  16. *
* * The substitution context includes package variables, configuration * properties, matrix parameters (for templates), and the configuration * object itself accessible via `configuration.name`, etc. * * @returns A promise that resolves to `true` if initialisation was performed, * or `false` if already initialised. * * @throws {@link ConfigurationError} * If substitutions fail. * * @throws {@link InputError} * If inheritance references are invalid or circular. */ async initialise(): Promise { const log = this._log log.trace(`${BuildConfiguration.name}.initialise()` + ` @${this.name}`) if (this._isInitialised) { log.trace( `${BuildConfiguration.name}.initialise()` + ` @${this.name} again` ) return false } log.trace(`${BuildConfiguration.name}.initialise()` + ` @${this.name}`) let localJsonBuildConfiguration: JsonBuildConfigurationContent if (this.isTemplate) { localJsonBuildConfiguration = await this._substituteTemplate() } else { localJsonBuildConfiguration = await this._substituteInherits() } // Add inherited properties, dependencies, devDependencies, and actions. const inheritedActionsMap = await this._processInherits( localJsonBuildConfiguration ) this.properties = { ...this.properties, ...localJsonBuildConfiguration.properties, } assert(this.name, 'buildConfigurationName missing') this._substitutionsVariables = { ...this.parentBuildConfigurations.substitutionsVariables, properties: { ...this._substitutionsVariables.properties, ...this.properties, }, matrix: this.matrixParameters ?? {}, configuration: { ...localJsonBuildConfiguration, name: this.name, }, } if (!this.isHidden) { this._buildFolderRelativePath = await this._getBuildFolderRelativePath() // Add the buildFolderRelativePath property. // Note: the async initialiser was needed due to this async operation. const properties = this._substitutionsVariables.properties properties.buildFolderRelativePath = this._buildFolderRelativePath } this.dependencies = { ...this.dependencies, ...localJsonBuildConfiguration.dependencies, } this.devDependencies = { ...this.devDependencies, ...localJsonBuildConfiguration.devDependencies, } const unsubstitutedDependencies = { dependencies: this.dependencies, devDependencies: this.devDependencies, } const stringifiedDependencies = JSON.stringify(unsubstitutedDependencies) if (hasLiquidSyntax(stringifiedDependencies)) { let substitutedDependencies try { substitutedDependencies = await performSubstitutions({ log, engine: this.parentBuildConfigurations.engine, input: stringifiedDependencies, substitutionsVariables: this._substitutionsVariables, }) } catch (error) { const message = getErrorMessage(error) + ` in buildConfiguration "${this.name}" dependencies` throw new ConfigurationError(message) } const parsedDependencies = JSON.parse( substitutedDependencies ) as JsonBuildConfigurationContent // Safety net: These fallbacks to empty objects handle cases where the // dependencies fields might be undefined after JSON parsing. This is // unlikely because the JSON schema validation ensures these are objects // when present, but provides robustness against malformed configuration // or future schema changes. /* c8 ignore start - safety net, they are always defined */ this.dependencies = parsedDependencies.dependencies ?? {} this.devDependencies = parsedDependencies.devDependencies ?? {} /* c8 ignore stop */ } this._actions = new Actions({ log: this._log, engine: this.parentBuildConfigurations.engine, substitutionsVariables: this._substitutionsVariables, inheritedActionsMap, jsonActions: localJsonBuildConfiguration.actions, buildConfiguration: this, }) log.trace( `${BuildConfiguration.name}.initialise() `, `@{this.buildConfigurationName}` ) if (!this.isHidden) { log.trace( this.name, 'buildFolderRelativePath =>', this._buildFolderRelativePath ) } log.trace(this.name, 'properties => ', this.properties) log.trace(this.name, 'dependencies => ', this.dependencies) log.trace(this.name, 'devDependencies => ', this.devDependencies) // Action names are not available at this point. // log.trace(this.buildConfigurationName, 'actions => ', // this._actions.names) this._isInitialised = true return true } // -------------------------------------------------------------------------- // Public Methods. /** * Retrieves the actions collection for this build configuration. * * @returns The actions collection. */ get actions(): Actions { assert( this._isInitialised, 'BuildConfiguration must be initialised before ' + 'accessing actions' ) assert(this._actions !== undefined, 'Actions not initialised') return this._actions } /** * Retrieves the build folder relative path for this configuration. * * @returns The build folder relative path. */ get buildFolderRelativePath(): string { assert( this._isInitialised, 'BuildConfiguration must be initialised before ' + 'accessing buildFolderRelativePath' ) assert( this._buildFolderRelativePath !== undefined, 'BuildConfiguration _buildFolderRelativePath not initialised' ) return this._buildFolderRelativePath } // -------------------------------------------------------------------------- // Private Methods. /** * Performs template substitution on the entire build configuration JSON. * * @remarks * This method is invoked during initialisation for template-generated * configurations to substitute matrix parameters throughout the entire * configuration definition. * * Processing steps: * *
    *
  1. Stringify the entire JSON build configuration object.
  2. *
  3. Check if the stringified JSON contains template syntax * (\{\{ or \{%).
  4. *
  5. If templates are found: *
      *
    • Perform Liquid substitutions with complete variable context * including matrix parameters.
    • *
    • Parse the substituted JSON string back into an object.
    • *
    *
  6. *
  7. If no templates are found, return the original configuration * as-is.
  8. *
* * This comprehensive substitution approach ensures matrix parameters can * be referenced anywhere within the configuration (properties, dependencies, * actions, etc.), which is necessary for template-generated configurations * but would be unnecessarily expensive for regular configurations. * * @returns A promise that resolves to the build configuration content with * all template variables substituted. * * @throws {@link ConfigurationError} * If Liquid template substitution fails. */ // eslint-disable-next-line max-len protected async _substituteTemplate(): Promise { const log = this._log // For templates, perform substitutions on the entire build // configuration JSON, since there can be matrix references everywhere. let localJsonBuildConfiguration: JsonBuildConfigurationContent const stringifiedJsonBuildConfiguration = JSON.stringify( this.jsonBuildConfiguration ) if (hasLiquidSyntax(stringifiedJsonBuildConfiguration)) { let substitutedJsonBuildConfiguration try { substitutedJsonBuildConfiguration = await performSubstitutions({ log, engine: this.parentBuildConfigurations.engine, input: stringifiedJsonBuildConfiguration, substitutionsVariables: { ...this._substitutionsVariables, // Safety net: This fallback ensures matrix is always an object. // matrixParameters should be defined when processing templates with // matrix expansion, but this handles edge cases where // initialisation // order or template logic might reference matrix before it's set. /* c8 ignore start - safety net, they are always defined */ matrix: this.matrixParameters ?? {}, /* c8 ignore stop */ configuration: { ...this.jsonBuildConfiguration, name: this.name, }, }, }) } catch (error) { const message = getErrorMessage(error) + ` in buildConfiguration "${this.name}"` throw new ConfigurationError(message) } localJsonBuildConfiguration = JSON.parse( substitutedJsonBuildConfiguration ) as JsonBuildConfigurationContent } else { localJsonBuildConfiguration = this.jsonBuildConfiguration } return localJsonBuildConfiguration } /** * Performs selective substitution on the inherits field only. * * @remarks * This method is invoked during initialisation for regular (non-template) * configurations to substitute template variables in the inheritance * specification whilst leaving other fields untouched until later processing. * * Processing steps: * *
    *
  1. Extract the inherits (or deprecated * inherit) field from the * configuration.
  2. *
  3. Stringify the inherits field and check for template syntax * (\{\{ or \{%).
  4. *
  5. If templates are found: *
      *
    • Perform Liquid substitutions with current variable context.
    • *
    • Parse the substituted JSON string back into an object.
    • *
    • Return a new configuration object with the substituted inherits * field and all other fields unchanged.
    • *
    *
  6. *
  7. If no templates are found, return the original configuration * as-is.
  8. *
* * This selective approach is more efficient than full JSON substitution * for regular configurations that do not have matrix parameters. The * remaining fields (properties, dependencies, actions) are processed * during inheritance resolution and dependency substitution phases. * * @returns A promise that resolves to the build configuration content with * the inherits field substituted. * * @throws {@link ConfigurationError} * If Liquid template substitution fails on the inherits field. */ // eslint-disable-next-line max-len protected async _substituteInherits(): Promise { const log = this._log let localJsonBuildConfiguration: JsonBuildConfigurationContent // For non-templates, first perform substitutions on 'inherits' only. // The rest of the entries are collected as-is and processed later. const stringifiedJsonInherits = JSON.stringify( this.jsonBuildConfiguration.inherits ?? {} ) if (hasLiquidSyntax(stringifiedJsonInherits)) { let substitutedJsonInherits try { substitutedJsonInherits = await performSubstitutions({ log, engine: this.parentBuildConfigurations.engine, input: stringifiedJsonInherits, substitutionsVariables: { ...this._substitutionsVariables, configuration: { ...this.jsonBuildConfiguration, name: this.name, }, }, }) } catch (error) { const message = getErrorMessage(error) + ` in buildConfiguration "${this.name}" inherits` throw new ConfigurationError(message) } localJsonBuildConfiguration = { ...this.jsonBuildConfiguration, inherits: JSON.parse( substitutedJsonInherits ) as JsonBuildConfigurationInherits, } } else { localJsonBuildConfiguration = this.jsonBuildConfiguration } return localJsonBuildConfiguration } /** * Parses the inherits field from JSON configuration. * * @remarks * This helper method extracts and normalises inheritance information from * the configuration, supporting both the current inherits * field and the deprecated inherit field. It handles both * string and array formats. * * Processing steps: * *
    *
  1. Check for inherits field (current standard).
  2. *
  3. Fall back to inherit field (deprecated).
  4. *
  5. Convert single strings to single-element arrays.
  6. *
  7. Join array elements with line breaks and split to handle * multi-line strings.
  8. *
* * @param localJsonBuildConfiguration - The JSON configuration content. * @returns Array of inherited configuration names. */ private _parseInheritsField( localJsonBuildConfiguration: JsonBuildConfigurationContent ): string[] { let jsonInherits: string[] = [] if (isString(localJsonBuildConfiguration.inherits)) { jsonInherits = [localJsonBuildConfiguration.inherits as string] } else if (Array.isArray(localJsonBuildConfiguration.inherits)) { jsonInherits = localJsonBuildConfiguration.inherits as string[] } else if (isString(localJsonBuildConfiguration.inherit)) { jsonInherits = [localJsonBuildConfiguration.inherit as string] } else if (Array.isArray(localJsonBuildConfiguration.inherit)) { jsonInherits = localJsonBuildConfiguration.inherit as string[] } if (jsonInherits.length > 0) { const joinedInherits = jsonInherits.join(os.EOL) return joinedInherits.split(os.EOL) } return jsonInherits } /** * Processes and merges a single inherited configuration. * * @remarks * This helper method handles the initialisation of a single inherited * configuration and merges its properties, dependencies, and actions into * the current configuration. * * Processing steps: * *
    *
  1. Detect circular references by checking the inherited names set.
  2. *
  3. Initialise the inherited configuration recursively.
  4. *
  5. Merge properties, dependencies, and devDependencies using spread * operator (later values override earlier ones).
  6. *
  7. Collect inherited actions into the provided map.
  8. *
* * @param inheritedBuildConfigurationName - Name of the configuration to * inherit from. * @param inheritedActionsMap - Map to accumulate inherited actions. * @returns A promise that resolves when processing is complete. * * @throws {@link InputError} * If a circular inheritance reference is detected. * * @throws {@link InputError} * If the inherited configuration name does not exist. */ private async _processInheritedConfiguration( inheritedBuildConfigurationName: string, inheritedActionsMap: Map ): Promise { if (inheritedBuildConfigurationName.trim() === '') { return } if ( !this.parentBuildConfigurations.hasJson(inheritedBuildConfigurationName) ) { throw new ConfigurationError( `buildConfiguration "${this.name}" ` + `inherits from missing "${inheritedBuildConfigurationName}"` ) } if (this._inheritedNamesSet.has(inheritedBuildConfigurationName)) { throw new ConfigurationError( `buildConfiguration "${this.name}" ` + `inherits from circular reference ` + `"${inheritedBuildConfigurationName}"` ) } this._inheritedNamesSet.add(inheritedBuildConfigurationName) const inheritedBuildConfiguration = this.parentBuildConfigurations.get( inheritedBuildConfigurationName ) await inheritedBuildConfiguration.initialise() // Merge properties, dependencies, devDependencies. // Later ones override earlier ones. this.properties = { ...this.properties, ...inheritedBuildConfiguration.properties, } this.dependencies = { ...this.dependencies, ...inheritedBuildConfiguration.dependencies, } this.devDependencies = { ...this.devDependencies, ...inheritedBuildConfiguration.devDependencies, } await inheritedBuildConfiguration.actions.initialise() for (const actionName of inheritedBuildConfiguration.actions.names) { const action = inheritedBuildConfiguration.actions.get(actionName) inheritedActionsMap.set(actionName, action) } } /** * Processes inheritance for a build configuration. * * @remarks * This method implements the inheritance resolution mechanism for build * configurations, enabling configurations to share properties, dependencies, * and actions by inheriting from one or more base configurations, * recursively. * * Processing workflow: * *
    *
  1. Extract inheritance specification from the configuration: *
      *
    • Supports both the current inherits field and the * deprecated inherit field for backwards * compatibility.
    • *
    • Handles both string format (single parent) and array format * (multiple parents).
    • *
    • Normalises line-separated names within array elements to support * multi-line specifications.
    • *
    *
  2. *
  3. Process each inherited configuration sequentially: *
      *
    • Skip empty names from the inheritance list.
    • *
    • Validate that the inherited configuration exists in the parent * collection.
    • *
    • Detect circular references by checking * _inheritedNamesSet.
    • *
    • Recursively initialise the inherited configuration (which may * itself have inheritance).
    • *
    *
  4. *
  5. Merge inherited content into the current configuration: *
      *
    • Properties: Later inherited configurations override earlier ones, * local properties override all inherited.
    • *
    • Dependencies and devDependencies: Same override behaviour as * properties.
    • *
    • Actions: Collected into a map where later definitions override * earlier ones with the same name.
    • *
    *
  6. *
* * The inheritance chain is processed depth-first, ensuring that transitive * inheritance (A inherits B, B inherits C) is fully resolved before merging * properties. Circular references are detected to prevent infinite recursion. * * @param localJsonBuildConfiguration - The JSON configuration content after * template or inherits field substitution. * @returns A promise that resolves to a map of inherited actions, where * keys are action names and values are action instances from all inherited * configurations. * * @throws {@link InputError} * If an inherited configuration name does not exist in the parent collection. * * @throws {@link InputError} * If a circular inheritance reference is detected. */ protected async _processInherits( localJsonBuildConfiguration: JsonBuildConfigurationContent ): Promise> { const log = this._log const inheritsNames = this._parseInheritsField(localJsonBuildConfiguration) this.inheritsNames = inheritsNames log.trace(this.name, 'inherits from', this.inheritsNames) const inheritedActionsMap: Map = new Map() for (const inheritedBuildConfigurationName of inheritsNames) { await this._processInheritedConfiguration( inheritedBuildConfigurationName, inheritedActionsMap ) } return inheritedActionsMap } /** * Computes the build folder relative path for this configuration. * * @remarks * This method resolves the build folder relative path property when * provided and uses a default value based on the configuration name * otherwise. * * Resolution strategy: * *
    *
  1. Check if buildFolderRelativePath property exists in configuration * properties.
  2. *
  3. If present and non-empty, perform Liquid substitutions with the * full configuration context.
  4. *
  5. If substitution fails or property is empty/missing, generate a * default path: `build/{filtered-configuration-name}` where the * configuration name is sanitized for filesystem compatibility.
  6. *
* * The computed path is added back to the properties as * `buildFolderRelativePath` for use in subsequent substitutions. * * @returns A promise that resolves to the build folder relative path. */ protected async _getBuildFolderRelativePath(): Promise { const log = this._log let folderPath: string if ( buildFolderRelativePathPropertyName in this._substitutionsVariables.properties ) { folderPath = this._substitutionsVariables.properties[ buildFolderRelativePathPropertyName ] as string if (folderPath !== '') { try { // log.trace(this.#substitutionsVariables.configuration) const substitutedFolderPath = await performSubstitutions({ log, engine: this.parentBuildConfigurations.engine, input: folderPath, substitutionsVariables: this._substitutionsVariables, }) return substitutedFolderPath } catch (error) { const message = getErrorMessage(error) + ` in buildConfiguration "${this.name}"` throw new ConfigurationError(message) } } } // Provide a default value, based on the name. const defaultFolderPath = path.join('build', filterPath(this.name)) return defaultFolderPath } } // ----------------------------------------------------------------------------