UNPKG

@rushstack/heft-config-file

Version:

Configuration file loader for @rushstack/heft

562 lines 30.6 kB
"use strict"; // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ConfigurationFileBase = exports.CONFIGURATION_FILE_FIELD_ANNOTATION = exports.PathResolutionMethod = exports.InheritanceType = void 0; const nodeJsPath = __importStar(require("path")); const jsonpath_plus_1 = require("jsonpath-plus"); const node_core_library_1 = require("@rushstack/node-core-library"); /** * @beta */ var InheritanceType; (function (InheritanceType) { /** * Append additional elements after elements from the parent file's property. Only applicable * for arrays. */ InheritanceType["append"] = "append"; /** * Perform a shallow merge of additional elements after elements from the parent file's property. * Only applicable for objects. */ InheritanceType["merge"] = "merge"; /** * Discard elements from the parent file's property */ InheritanceType["replace"] = "replace"; /** * Custom inheritance functionality */ InheritanceType["custom"] = "custom"; })(InheritanceType || (exports.InheritanceType = InheritanceType = {})); /** * @beta */ var PathResolutionMethod; (function (PathResolutionMethod) { /** * Resolve a path relative to the configuration file */ PathResolutionMethod["resolvePathRelativeToConfigurationFile"] = "resolvePathRelativeToConfigurationFile"; /** * Resolve a path relative to the root of the project containing the configuration file */ PathResolutionMethod["resolvePathRelativeToProjectRoot"] = "resolvePathRelativeToProjectRoot"; /** * Treat the property as a NodeJS-style require/import reference and resolve using standard * NodeJS filesystem resolution * * @deprecated * Use {@link PathResolutionMethod.nodeResolve} instead */ PathResolutionMethod["NodeResolve"] = "NodeResolve"; /** * Treat the property as a NodeJS-style require/import reference and resolve using standard * NodeJS filesystem resolution */ PathResolutionMethod["nodeResolve"] = "nodeResolve"; /** * Resolve the property using a custom resolver. */ PathResolutionMethod["custom"] = "custom"; })(PathResolutionMethod || (exports.PathResolutionMethod = PathResolutionMethod = {})); const CONFIGURATION_FILE_MERGE_BEHAVIOR_FIELD_REGEX = /^\$([^\.]+)\.inheritanceType$/; exports.CONFIGURATION_FILE_FIELD_ANNOTATION = Symbol('configuration-file-field-annotation'); /** * @beta */ class ConfigurationFileBase { get _schema() { if (!this.__schema) { this.__schema = this._getSchema(); } return this.__schema; } constructor(options) { this._configCache = new Map(); this._configPromiseCache = new Map(); this._packageJsonLookup = new node_core_library_1.PackageJsonLookup(); if (options.jsonSchemaObject) { this._getSchema = () => node_core_library_1.JsonSchema.fromLoadedObject(options.jsonSchemaObject); } else { this._getSchema = () => node_core_library_1.JsonSchema.fromFile(options.jsonSchemaPath); } this._jsonPathMetadata = options.jsonPathMetadata || {}; this._propertyInheritanceTypes = options.propertyInheritance || {}; this._defaultPropertyInheritance = options.propertyInheritanceDefaults || {}; } /** * Get the path to the source file that the referenced property was originally * loaded from. */ getObjectSourceFilePath(obj) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const annotation = obj[exports.CONFIGURATION_FILE_FIELD_ANNOTATION]; if (annotation) { return annotation.configurationFilePath; } return undefined; } /** * Get the value of the specified property on the specified object that was originally * loaded from a configuration file. */ getPropertyOriginalValue(options) { const annotation = // eslint-disable-next-line @typescript-eslint/no-explicit-any options.parentObject[exports.CONFIGURATION_FILE_FIELD_ANNOTATION]; if (annotation && annotation.originalValues.hasOwnProperty(options.propertyName)) { return annotation.originalValues[options.propertyName]; } else { return undefined; } } _loadConfigurationFileInnerWithCache(terminal, resolvedConfigurationFilePath, visitedConfigurationFilePaths, rigConfig) { if (visitedConfigurationFilePaths.has(resolvedConfigurationFilePath)) { const resolvedConfigurationFilePathForLogging = ConfigurationFileBase._formatPathForLogging(resolvedConfigurationFilePath); throw new Error('A loop has been detected in the "extends" properties of configuration file at ' + `"${resolvedConfigurationFilePathForLogging}".`); } visitedConfigurationFilePaths.add(resolvedConfigurationFilePath); let cacheEntry = this._configCache.get(resolvedConfigurationFilePath); if (!cacheEntry) { cacheEntry = this._loadConfigurationFileInner(terminal, resolvedConfigurationFilePath, visitedConfigurationFilePaths, rigConfig); this._configCache.set(resolvedConfigurationFilePath, cacheEntry); } return cacheEntry; } async _loadConfigurationFileInnerWithCacheAsync(terminal, resolvedConfigurationFilePath, visitedConfigurationFilePaths, rigConfig) { if (visitedConfigurationFilePaths.has(resolvedConfigurationFilePath)) { const resolvedConfigurationFilePathForLogging = ConfigurationFileBase._formatPathForLogging(resolvedConfigurationFilePath); throw new Error('A loop has been detected in the "extends" properties of configuration file at ' + `"${resolvedConfigurationFilePathForLogging}".`); } visitedConfigurationFilePaths.add(resolvedConfigurationFilePath); let cacheEntryPromise = this._configPromiseCache.get(resolvedConfigurationFilePath); if (!cacheEntryPromise) { cacheEntryPromise = this._loadConfigurationFileInnerAsync(terminal, resolvedConfigurationFilePath, visitedConfigurationFilePaths, rigConfig).then((value) => { this._configCache.set(resolvedConfigurationFilePath, value); return value; }); this._configPromiseCache.set(resolvedConfigurationFilePath, cacheEntryPromise); } return await cacheEntryPromise; } _parseAndResolveConfigurationFile(fileText, resolvedConfigurationFilePath, resolvedConfigurationFilePathForLogging) { let configurationJson; try { configurationJson = node_core_library_1.JsonFile.parseString(fileText); } catch (e) { throw new Error(`In config file "${resolvedConfigurationFilePathForLogging}": ${e}`); } this._annotateProperties(resolvedConfigurationFilePath, configurationJson); for (const [jsonPath, metadata] of Object.entries(this._jsonPathMetadata)) { (0, jsonpath_plus_1.JSONPath)({ path: jsonPath, json: configurationJson, callback: (payload, payloadType, fullPayload) => { const resolvedPath = this._resolvePathProperty({ propertyName: fullPayload.path, propertyValue: fullPayload.value, configurationFilePath: resolvedConfigurationFilePath, configurationFile: configurationJson }, metadata); // eslint-disable-next-line @typescript-eslint/no-explicit-any fullPayload.parent[fullPayload.parentProperty] = resolvedPath; }, otherTypeCallback: () => { throw new Error('@other() tags are not supported'); } }); } return configurationJson; } // NOTE: Internal calls to load a configuration file should use `_loadConfigurationFileInnerWithCache`. // Don't call this function directly, as it does not provide config file loop detection, // and you won't get the advantage of queueing up for a config file that is already loading. _loadConfigurationFileInner(terminal, resolvedConfigurationFilePath, visitedConfigurationFilePaths, rigConfig) { const resolvedConfigurationFilePathForLogging = ConfigurationFileBase._formatPathForLogging(resolvedConfigurationFilePath); let fileText; try { fileText = node_core_library_1.FileSystem.readFile(resolvedConfigurationFilePath); } catch (e) { if (node_core_library_1.FileSystem.isNotExistError(e)) { if (rigConfig) { terminal.writeDebugLine(`Config file "${resolvedConfigurationFilePathForLogging}" does not exist. Attempting to load via rig.`); const rigResult = this._tryLoadConfigurationFileInRig(terminal, rigConfig, visitedConfigurationFilePaths); if (rigResult) { return rigResult; } } else { terminal.writeDebugLine(`Configuration file "${resolvedConfigurationFilePathForLogging}" not found.`); } e.message = `File does not exist: ${resolvedConfigurationFilePathForLogging}`; } throw e; } const configurationJson = this._parseAndResolveConfigurationFile(fileText, resolvedConfigurationFilePath, resolvedConfigurationFilePathForLogging); let parentConfiguration; if (configurationJson.extends) { try { const resolvedParentConfigPath = node_core_library_1.Import.resolveModule({ modulePath: configurationJson.extends, baseFolderPath: nodeJsPath.dirname(resolvedConfigurationFilePath) }); parentConfiguration = this._loadConfigurationFileInnerWithCache(terminal, resolvedParentConfigPath, visitedConfigurationFilePaths, undefined); } catch (e) { if (node_core_library_1.FileSystem.isNotExistError(e)) { throw new Error(`In file "${resolvedConfigurationFilePathForLogging}", file referenced in "extends" property ` + `("${configurationJson.extends}") cannot be resolved.`); } else { throw e; } } } const result = this._mergeConfigurationFiles(parentConfiguration || {}, configurationJson, resolvedConfigurationFilePath); try { this._schema.validateObject(result, resolvedConfigurationFilePathForLogging); } catch (e) { throw new Error(`Resolved configuration object does not match schema: ${e}`); } // If the schema validates, we can assume that the configuration file is complete. return result; } // NOTE: Internal calls to load a configuration file should use `_loadConfigurationFileInnerWithCacheAsync`. // Don't call this function directly, as it does not provide config file loop detection, // and you won't get the advantage of queueing up for a config file that is already loading. async _loadConfigurationFileInnerAsync(terminal, resolvedConfigurationFilePath, visitedConfigurationFilePaths, rigConfig) { const resolvedConfigurationFilePathForLogging = ConfigurationFileBase._formatPathForLogging(resolvedConfigurationFilePath); let fileText; try { fileText = await node_core_library_1.FileSystem.readFileAsync(resolvedConfigurationFilePath); } catch (e) { if (node_core_library_1.FileSystem.isNotExistError(e)) { if (rigConfig) { terminal.writeDebugLine(`Config file "${resolvedConfigurationFilePathForLogging}" does not exist. Attempting to load via rig.`); const rigResult = await this._tryLoadConfigurationFileInRigAsync(terminal, rigConfig, visitedConfigurationFilePaths); if (rigResult) { return rigResult; } } else { terminal.writeDebugLine(`Configuration file "${resolvedConfigurationFilePathForLogging}" not found.`); } e.message = `File does not exist: ${resolvedConfigurationFilePathForLogging}`; } throw e; } const configurationJson = this._parseAndResolveConfigurationFile(fileText, resolvedConfigurationFilePath, resolvedConfigurationFilePathForLogging); let parentConfiguration; if (configurationJson.extends) { try { const resolvedParentConfigPath = node_core_library_1.Import.resolveModule({ modulePath: configurationJson.extends, baseFolderPath: nodeJsPath.dirname(resolvedConfigurationFilePath) }); parentConfiguration = await this._loadConfigurationFileInnerWithCacheAsync(terminal, resolvedParentConfigPath, visitedConfigurationFilePaths, undefined); } catch (e) { if (node_core_library_1.FileSystem.isNotExistError(e)) { throw new Error(`In file "${resolvedConfigurationFilePathForLogging}", file referenced in "extends" property ` + `("${configurationJson.extends}") cannot be resolved.`); } else { throw e; } } } const result = this._mergeConfigurationFiles(parentConfiguration || {}, configurationJson, resolvedConfigurationFilePath); try { this._schema.validateObject(result, resolvedConfigurationFilePathForLogging); } catch (e) { throw new Error(`Resolved configuration object does not match schema: ${e}`); } // If the schema validates, we can assume that the configuration file is complete. return result; } _annotateProperties(resolvedConfigurationFilePath, obj) { if (!obj) { return; } if (typeof obj === 'object') { this._annotateProperty(resolvedConfigurationFilePath, obj); for (const objValue of Object.values(obj)) { this._annotateProperties(resolvedConfigurationFilePath, objValue); } } } _annotateProperty(resolvedConfigurationFilePath, obj) { if (!obj) { return; } if (typeof obj === 'object') { obj[exports.CONFIGURATION_FILE_FIELD_ANNOTATION] = { configurationFilePath: resolvedConfigurationFilePath, originalValues: Object.assign({}, obj) }; } } _resolvePathProperty(resolverOptions, metadata) { const { propertyValue, configurationFilePath } = resolverOptions; const resolutionMethod = metadata.pathResolutionMethod; if (resolutionMethod === undefined) { return propertyValue; } switch (metadata.pathResolutionMethod) { case PathResolutionMethod.resolvePathRelativeToConfigurationFile: { return nodeJsPath.resolve(nodeJsPath.dirname(configurationFilePath), propertyValue); } case PathResolutionMethod.resolvePathRelativeToProjectRoot: { const packageRoot = this._packageJsonLookup.tryGetPackageFolderFor(configurationFilePath); if (!packageRoot) { throw new Error(`Could not find a package root for path "${ConfigurationFileBase._formatPathForLogging(configurationFilePath)}"`); } return nodeJsPath.resolve(packageRoot, propertyValue); } case PathResolutionMethod.NodeResolve: // TODO: Remove case PathResolutionMethod.nodeResolve: { return node_core_library_1.Import.resolveModule({ modulePath: propertyValue, baseFolderPath: nodeJsPath.dirname(configurationFilePath) }); } case PathResolutionMethod.custom: { if (!metadata.customResolver) { throw new Error(`The pathResolutionMethod was set to "${PathResolutionMethod[resolutionMethod]}", but a custom ` + 'resolver was not provided.'); } return metadata.customResolver(resolverOptions); } default: { throw new Error(`Unsupported PathResolutionMethod: ${PathResolutionMethod[resolutionMethod]} (${resolutionMethod})`); } } } _mergeConfigurationFiles(parentConfiguration, configurationJson, resolvedConfigurationFilePath) { const ignoreProperties = new Set(['extends', '$schema']); // Need to do a dance with the casting here because while we know that JSON keys are always // strings, TypeScript doesn't. return this._mergeObjects(parentConfiguration, configurationJson, resolvedConfigurationFilePath, this._defaultPropertyInheritance, this._propertyInheritanceTypes, ignoreProperties); } _mergeObjects(parentObject, currentObject, resolvedConfigurationFilePath, defaultPropertyInheritance, configuredPropertyInheritance, ignoreProperties) { var _a, _b, _c; const resultAnnotation = { configurationFilePath: resolvedConfigurationFilePath, originalValues: {} }; const result = { [exports.CONFIGURATION_FILE_FIELD_ANNOTATION]: resultAnnotation }; // An array of property names that are on the merging object. Typed as Set<string> since it may // contain inheritance type annotation keys, or other built-in properties that we ignore // (eg. "extends", "$schema"). const currentObjectPropertyNames = new Set(Object.keys(currentObject)); // An array of property names that should be included in the resulting object. const filteredObjectPropertyNames = []; // A map of property names to their inheritance type. const inheritanceTypeMap = new Map(); // Do a first pass to gather and strip the inheritance type annotations from the merging object. for (const propertyName of currentObjectPropertyNames) { if (ignoreProperties && ignoreProperties.has(propertyName)) { continue; } // Try to get the inheritance type annotation from the merging object using the regex. // Note: since this regex matches a specific style of property name, we should not need to // allow for any escaping of $-prefixed properties. If this ever changes (eg. to allow for // `"$propertyName": { ... }` options), then we'll likely need to handle that error case, // as well as allow escaping $-prefixed properties that developers want to be serialized, // possibly by using the form `$$propertyName` to escape `$propertyName`. const inheritanceTypeMatches = propertyName.match(CONFIGURATION_FILE_MERGE_BEHAVIOR_FIELD_REGEX); if (inheritanceTypeMatches) { // Should always be of length 2, since the first match is the entire string and the second // match is the capture group. const mergeTargetPropertyName = inheritanceTypeMatches[1]; const inheritanceTypeRaw = currentObject[propertyName]; if (!currentObjectPropertyNames.has(mergeTargetPropertyName)) { throw new Error(`Issue in processing configuration file property "${propertyName}". ` + `An inheritance type was provided but no matching property was found in the parent.`); } else if (typeof inheritanceTypeRaw !== 'string') { throw new Error(`Issue in processing configuration file property "${propertyName}". ` + `An unsupported inheritance type was provided: ${JSON.stringify(inheritanceTypeRaw)}`); } else if (typeof currentObject[mergeTargetPropertyName] !== 'object') { throw new Error(`Issue in processing configuration file property "${propertyName}". ` + `An inheritance type was provided for a property that is not a keyed object or array.`); } switch (inheritanceTypeRaw.toLowerCase()) { case 'append': inheritanceTypeMap.set(mergeTargetPropertyName, { inheritanceType: InheritanceType.append }); break; case 'merge': inheritanceTypeMap.set(mergeTargetPropertyName, { inheritanceType: InheritanceType.merge }); break; case 'replace': inheritanceTypeMap.set(mergeTargetPropertyName, { inheritanceType: InheritanceType.replace }); break; default: throw new Error(`Issue in processing configuration file property "${propertyName}". ` + `An unsupported inheritance type was provided: "${inheritanceTypeRaw}"`); } } else { filteredObjectPropertyNames.push(propertyName); } } // We only filter the currentObject because the parent object should already be filtered const propertyNames = new Set([ ...Object.keys(parentObject), ...filteredObjectPropertyNames ]); // Cycle through properties and merge them for (const propertyName of propertyNames) { const propertyValue = currentObject[propertyName]; const parentPropertyValue = parentObject[propertyName]; let newValue; const usePropertyValue = () => { resultAnnotation.originalValues[propertyName] = this.getPropertyOriginalValue({ parentObject: currentObject, propertyName: propertyName }); newValue = propertyValue; }; const useParentPropertyValue = () => { resultAnnotation.originalValues[propertyName] = this.getPropertyOriginalValue({ parentObject: parentObject, propertyName: propertyName }); newValue = parentPropertyValue; }; if (propertyValue !== undefined && parentPropertyValue === undefined) { usePropertyValue(); } else if (parentPropertyValue !== undefined && propertyValue === undefined) { useParentPropertyValue(); } else if (propertyValue !== undefined && parentPropertyValue !== undefined) { // If the property is an inheritance type annotation, use it, otherwise fallback to the configured // top-level property inheritance, if one is specified. let propertyInheritance = (_a = inheritanceTypeMap.get(propertyName)) !== null && _a !== void 0 ? _a : configuredPropertyInheritance === null || configuredPropertyInheritance === void 0 ? void 0 : configuredPropertyInheritance[propertyName]; if (!propertyInheritance) { const bothAreArrays = Array.isArray(propertyValue) && Array.isArray(parentPropertyValue); if (bothAreArrays) { // If both are arrays, use the configured default array inheritance and fallback to appending // if one is not specified propertyInheritance = (_b = defaultPropertyInheritance.array) !== null && _b !== void 0 ? _b : { inheritanceType: InheritanceType.append }; } else { const bothAreObjects = propertyValue && parentPropertyValue && typeof propertyValue === 'object' && typeof parentPropertyValue === 'object'; if (bothAreObjects) { // If both are objects, use the configured default object inheritance and fallback to replacing // if one is not specified propertyInheritance = (_c = defaultPropertyInheritance.object) !== null && _c !== void 0 ? _c : { inheritanceType: InheritanceType.replace }; } else { // Fall back to replacing if they are of different types, since we don't know how to merge these propertyInheritance = { inheritanceType: InheritanceType.replace }; } } } switch (propertyInheritance.inheritanceType) { case InheritanceType.replace: { usePropertyValue(); break; } case InheritanceType.append: { if (!Array.isArray(propertyValue) || !Array.isArray(parentPropertyValue)) { throw new Error(`Issue in processing configuration file property "${propertyName.toString()}". ` + `Property is not an array, but the inheritance type is set as "${InheritanceType.append}"`); } newValue = [...parentPropertyValue, ...propertyValue]; newValue[exports.CONFIGURATION_FILE_FIELD_ANNOTATION] = { configurationFilePath: undefined, originalValues: Object.assign(Object.assign({}, parentPropertyValue[exports.CONFIGURATION_FILE_FIELD_ANNOTATION].originalValues), propertyValue[exports.CONFIGURATION_FILE_FIELD_ANNOTATION].originalValues) }; break; } case InheritanceType.merge: { if (parentPropertyValue === null || propertyValue === null) { throw new Error(`Issue in processing configuration file property "${propertyName.toString()}". ` + `Null values cannot be used when the inheritance type is set as "${InheritanceType.merge}"`); } else if ((propertyValue && typeof propertyValue !== 'object') || (parentPropertyValue && typeof parentPropertyValue !== 'object')) { throw new Error(`Issue in processing configuration file property "${propertyName.toString()}". ` + `Primitive types cannot be provided when the inheritance type is set as "${InheritanceType.merge}"`); } else if (Array.isArray(propertyValue) || Array.isArray(parentPropertyValue)) { throw new Error(`Issue in processing configuration file property "${propertyName.toString()}". ` + `Property is not a keyed object, but the inheritance type is set as "${InheritanceType.merge}"`); } // Recursively merge the parent and child objects. Don't pass the configuredPropertyInheritance or // ignoreProperties because we are no longer at the top level of the configuration file. We also know // that it must be a string-keyed object, since the JSON spec requires it. newValue = this._mergeObjects(parentPropertyValue, propertyValue, resolvedConfigurationFilePath, defaultPropertyInheritance); break; } case InheritanceType.custom: { const customInheritance = propertyInheritance; if (!customInheritance.inheritanceFunction || typeof customInheritance.inheritanceFunction !== 'function') { throw new Error('For property inheritance type "InheritanceType.custom", an inheritanceFunction must be provided.'); } newValue = customInheritance.inheritanceFunction(propertyValue, parentPropertyValue); break; } default: { throw new Error(`Unknown inheritance type "${propertyInheritance}"`); } } } result[propertyName] = newValue; } return result; } } exports.ConfigurationFileBase = ConfigurationFileBase; /** * @internal */ ConfigurationFileBase._formatPathForLogging = (path) => path; //# sourceMappingURL=ConfigurationFileBase.js.map