From f78eb1280daae5fe3fb141615249dd303cdea7f7 Mon Sep 17 00:00:00 2001 From: Daniel Nadeau <3473356+D4N14L@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:44:19 -0700 Subject: [PATCH 1/3] Validate configuration file against legacy schema if normal schema is broken --- apps/heft/src/schemas/heft-legacy.schema.json | 214 ++++++++++++++++++ apps/heft/src/utilities/CoreConfigFiles.ts | 34 ++- 2 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 apps/heft/src/schemas/heft-legacy.schema.json diff --git a/apps/heft/src/schemas/heft-legacy.schema.json b/apps/heft/src/schemas/heft-legacy.schema.json new file mode 100644 index 00000000000..16f57a4d434 --- /dev/null +++ b/apps/heft/src/schemas/heft-legacy.schema.json @@ -0,0 +1,214 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Legacy Heft Configuration", + "description": "Defines configuration used by the legacy version of Heft.", + "type": "object", + + "definitions": { + "anything": { + "type": ["array", "boolean", "integer", "number", "object", "string"], + "items": { "$ref": "#/definitions/anything" } + } + }, + + "additionalProperties": false, + + "properties": { + "$schema": { + "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", + "type": "string" + }, + + "extends": { + "description": "Optionally specifies another JSON config file that this file extends from. This provides a way for standard settings to be shared across multiple projects.", + "type": "string" + }, + + "eventActions": { + "type": "array", + "description": "An array of actions (such as deleting files or folders) that should occur during a Heft run.", + + "items": { + "type": "object", + "required": ["actionKind", "heftEvent", "actionId"], + "allOf": [ + { + "properties": { + "actionKind": { + "type": "string", + "description": "The kind of built-in operation that should be performed.", + "enum": ["deleteGlobs", "copyFiles", "runScript"] + }, + + "heftEvent": { + "type": "string", + "description": "The Heft stage when this action should be performed. Note that heft.json event actions are scheduled after any plugin tasks have processed the event. For example, a \"compile\" event action will be performed after the TypeScript compiler has been invoked.", + "enum": ["clean", "pre-compile", "compile", "bundle", "post-build", "test"] + }, + + "actionId": { + "type": "string", + "description": "A user-defined tag whose purpose is to allow configs to replace/delete handlers that were added by other configs." + } + } + }, + { + "oneOf": [ + { + "required": ["globsToDelete"], + "properties": { + "actionKind": { + "type": "string", + "enum": ["deleteGlobs"] + }, + + "heftEvent": { + "type": "string", + "enum": ["clean", "pre-compile", "compile", "bundle", "post-build"] + }, + + "globsToDelete": { + "type": "array", + "description": "Glob patterns to be deleted. The paths are resolved relative to the project folder.", + "items": { + "type": "string", + "pattern": "[^\\\\]" + } + } + } + }, + { + "required": ["copyOperations"], + "properties": { + "actionKind": { + "type": "string", + "enum": ["copyFiles"] + }, + + "heftEvent": { + "type": "string", + "enum": ["pre-compile", "compile", "bundle", "post-build"] + }, + + "copyOperations": { + "type": "array", + "description": "An array of copy operations to run perform during the specified Heft event.", + "items": { + "type": "object", + "required": ["sourceFolder", "destinationFolders"], + "properties": { + "sourceFolder": { + "type": "string", + "description": "The base folder that files will be copied from, relative to the project root. Settings such as \"includeGlobs\" and \"excludeGlobs\" will be resolved relative to this folder. NOTE: Assigning \"sourceFolder\" does not by itself select any files to be copied.", + "pattern": "[^\\\\]" + }, + + "destinationFolders": { + "type": "array", + "description": "One or more folders that files will be copied into, relative to the project root. If you specify more than one destination folder, Heft will read the input files only once, using streams to efficiently write multiple outputs.", + "items": { + "type": "string", + "pattern": "[^\\\\]" + } + }, + + "fileExtensions": { + "type": "array", + "description": "If specified, this option recursively scans all folders under \"sourceFolder\" and includes any files that match the specified extensions. (If \"fileExtensions\" and \"includeGlobs\" are both specified, their selections are added together.)", + "items": { + "type": "string", + "pattern": "^\\.[A-z0-9-_.]*[A-z0-9-_]+$" + } + }, + + "excludeGlobs": { + "type": "array", + "description": "A list of glob patterns that exclude files/folders from being copied. The paths are resolved relative to \"sourceFolder\". These exclusions eliminate items that were selected by the \"includeGlobs\" or \"fileExtensions\" setting.", + "items": { + "type": "string", + "pattern": "[^\\\\]" + } + }, + + "includeGlobs": { + "type": "array", + "description": "A list of glob patterns that select files to be copied. The paths are resolved relative to \"sourceFolder\".", + "items": { + "type": "string", + "pattern": "[^\\\\]" + } + }, + + "flatten": { + "type": "boolean", + "description": "Normally, when files are selected under a child folder, a corresponding folder will be created in the destination folder. Specify flatten=true to discard the source path and copy all matching files to the same folder. If two files have the same name an error will be reported. The default value is false." + }, + + "hardlink": { + "type": "boolean", + "description": "If true, filesystem hard links will be created instead of copying the file. Depending on the operating system, this may be faster. (But note that it may cause unexpected behavior if a tool modifies the link.) The default value is false." + } + } + } + } + } + }, + { + "required": ["scriptPath"], + "properties": { + "actionKind": { + "type": "string", + "enum": ["runScript"] + }, + + "heftEvent": { + "type": "string", + "enum": ["pre-compile", "compile", "bundle", "post-build", "test"] + }, + + "scriptPath": { + "type": "string", + "description": "Path to the script that will be run, relative to the project root.", + "items": { + "type": "string", + "pattern": "[^\\\\]" + } + }, + + "scriptOptions": { + "type": "object", + "description": "Optional parameters that will be passed to the script at runtime.", + "patternProperties": { + "^.*$": { "$ref": "#/definitions/anything" } + } + } + } + } + ] + } + ] + } + }, + + "heftPlugins": { + "type": "array", + "description": "Defines heft plugins that are used by a project.", + + "items": { + "type": "object", + "required": ["plugin"], + "properties": { + "plugin": { + "description": "Path to the plugin package, relative to the project root.", + "type": "string", + "pattern": "[^\\\\]" + }, + + "options": { + "type": "object" + } + } + } + } + } +} diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index 55aafe0b9a2..7cfc6d7f4d9 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -111,12 +111,42 @@ export class CoreConfigFiles { }); } - const configurationFile: IHeftConfigurationJson = - await CoreConfigFiles._heftConfigFileLoader.loadConfigurationFileForProjectAsync( + let configurationFile: IHeftConfigurationJson; + try { + configurationFile = await CoreConfigFiles._heftConfigFileLoader.loadConfigurationFileForProjectAsync( terminal, projectPath, rigConfig ); + } catch (e: unknown) { + if (!(e as Error).message.startsWith('Resolved configuration object does not match schema')) { + throw e; + } + + try { + // If the config file doesn't match the schema, then we should check to see if it does + // match the legacy schema. We don't need to worry about the resulting object, we just + // want to see if it parses. We will use the ConfigurationFile class to load it to ensure + // that we follow the "extends" chain for the entire config file. + const legacySchemaPath: string = path.join(__dirname, '..', 'schemas', 'heft-legacy.schema.json'); + const legacyConfigFileLoader: ConfigurationFile = new ConfigurationFile({ + projectRelativeFilePath: `${Constants.projectConfigFolderName}/${Constants.heftConfigurationFilename}`, + jsonSchemaPath: legacySchemaPath + }); + await legacyConfigFileLoader.loadConfigurationFileForProjectAsync(terminal, projectPath, rigConfig); + } catch (e2) { + // It doesn't match the legacy schema either. Throw the original error. + throw e; + } + // Matches the legacy schema, so throw a more helpful error. + throw new Error( + "This project's Heft configuration appears to be using an outdated schema.\n\n" + + 'Heft 0.51.0 introduced a major breaking change for Heft configuration files. ' + + 'Your project appears to be using the older file format. You will need to ' + + 'migrate your project to the new format. Follow these instructions: ' + + 'https://rushstack.io/link/heft-0.51' + ); + } // The pluginPackage field was resolved to the root of the package, but we also want to have // the original plugin package name in the config file. Gather all the plugin specifiers so we can From 1d6ced944df4bd5ecb33228d1b210ff7b74528eb Mon Sep 17 00:00:00 2001 From: Daniel Nadeau <3473356+D4N14L@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:49:00 -0700 Subject: [PATCH 2/3] Rush change --- .../user-danade-CheckLegacyHeft_2023-06-07-22-48.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@rushstack/heft/user-danade-CheckLegacyHeft_2023-06-07-22-48.json diff --git a/common/changes/@rushstack/heft/user-danade-CheckLegacyHeft_2023-06-07-22-48.json b/common/changes/@rushstack/heft/user-danade-CheckLegacyHeft_2023-06-07-22-48.json new file mode 100644 index 00000000000..8f40b400a05 --- /dev/null +++ b/common/changes/@rushstack/heft/user-danade-CheckLegacyHeft_2023-06-07-22-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Provide a useful error message when encountering legacy Heft configurations", + "type": "patch" + } + ], + "packageName": "@rushstack/heft" +} \ No newline at end of file From b592572c97944eb1b5b5abac263f4e486837637e Mon Sep 17 00:00:00 2001 From: Daniel Nadeau <3473356+D4N14L@users.noreply.github.com> Date: Wed, 7 Jun 2023 16:45:27 -0700 Subject: [PATCH 3/3] PR feedback --- apps/heft/src/plugins/NodeServicePlugin.ts | 13 ++++--- apps/heft/src/utilities/CoreConfigFiles.ts | 42 +++++++++++++++------- apps/heft/tsconfig.json | 3 +- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/heft/src/plugins/NodeServicePlugin.ts b/apps/heft/src/plugins/NodeServicePlugin.ts index ef6ae4bf532..39bfe0dd83d 100644 --- a/apps/heft/src/plugins/NodeServicePlugin.ts +++ b/apps/heft/src/plugins/NodeServicePlugin.ts @@ -125,12 +125,11 @@ export default class NodeServicePlugin implements IHeftTaskPlugin { heftConfiguration: HeftConfiguration ): Promise { if (!this._rawConfiguration) { - this._rawConfiguration = - await CoreConfigFiles.nodeServiceConfigurationFile.tryLoadConfigurationFileForProjectAsync( - taskSession.logger.terminal, - heftConfiguration.buildFolderPath, - heftConfiguration.rigConfig - ); + this._rawConfiguration = await CoreConfigFiles.tryLoadNodeServiceConfigurationFileAsync( + taskSession.logger.terminal, + heftConfiguration.buildFolderPath, + heftConfiguration.rigConfig + ); // defaults this._configuration = { @@ -178,7 +177,7 @@ export default class NodeServicePlugin implements IHeftTaskPlugin { } else { throw new Error( 'The node service cannot be started because the task config file was not found: ' + - CoreConfigFiles.nodeServiceConfigurationFile.projectRelativeFilePath + CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath ); } } diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index 7cfc6d7f4d9..f9d398cd549 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -61,6 +61,10 @@ export class CoreConfigFiles { | ConfigurationFile | undefined; + public static heftConfigurationProjectRelativeFilePath: string = `${Constants.projectConfigFolderName}/${Constants.heftConfigurationFilename}`; + + public static nodeServiceConfigurationProjectRelativeFilePath: string = `${Constants.projectConfigFolderName}/${Constants.nodeServiceConfigurationFilename}`; + /** * Returns the loader for the `config/heft.json` config file. */ @@ -81,10 +85,10 @@ export class CoreConfigFiles { }); }; - const schemaPath: string = path.join(__dirname, '..', 'schemas', 'heft.schema.json'); + const schemaObject: object = await import('../schemas/heft.schema.json'); CoreConfigFiles._heftConfigFileLoader = new ConfigurationFile({ - projectRelativeFilePath: `${Constants.projectConfigFolderName}/${Constants.heftConfigurationFilename}`, - jsonSchemaPath: schemaPath, + projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, + jsonSchemaObject: schemaObject, propertyInheritanceDefaults: { array: { inheritanceType: InheritanceType.append }, object: { inheritanceType: InheritanceType.merge } @@ -119,7 +123,10 @@ export class CoreConfigFiles { rigConfig ); } catch (e: unknown) { - if (!(e as Error).message.startsWith('Resolved configuration object does not match schema')) { + if ( + !(e instanceof Error) || + !e.message.startsWith('Resolved configuration object does not match schema') + ) { throw e; } @@ -128,10 +135,10 @@ export class CoreConfigFiles { // match the legacy schema. We don't need to worry about the resulting object, we just // want to see if it parses. We will use the ConfigurationFile class to load it to ensure // that we follow the "extends" chain for the entire config file. - const legacySchemaPath: string = path.join(__dirname, '..', 'schemas', 'heft-legacy.schema.json'); + const legacySchemaObject: object = await import('../schemas/heft-legacy.schema.json'); const legacyConfigFileLoader: ConfigurationFile = new ConfigurationFile({ - projectRelativeFilePath: `${Constants.projectConfigFolderName}/${Constants.heftConfigurationFilename}`, - jsonSchemaPath: legacySchemaPath + projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, + jsonSchemaObject: legacySchemaObject }); await legacyConfigFileLoader.loadConfigurationFileForProjectAsync(terminal, projectPath, rigConfig); } catch (e2) { @@ -174,15 +181,26 @@ export class CoreConfigFiles { return configurationFile; } - public static get nodeServiceConfigurationFile(): ConfigurationFile { + public static async tryLoadNodeServiceConfigurationFileAsync( + terminal: ITerminal, + projectPath: string, + rigConfig?: RigConfig | undefined + ): Promise { if (!CoreConfigFiles._nodeServiceConfigurationLoader) { - const schemaPath: string = path.resolve(__dirname, '..', 'schemas', 'node-service.schema.json'); + const schemaObject: object = await import('../schemas/node-service.schema.json'); CoreConfigFiles._nodeServiceConfigurationLoader = new ConfigurationFile({ - projectRelativeFilePath: `${Constants.projectConfigFolderName}/${Constants.nodeServiceConfigurationFilename}`, - jsonSchemaPath: schemaPath + projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, + jsonSchemaObject: schemaObject }); } - return CoreConfigFiles._nodeServiceConfigurationLoader; + + const configurationFile: INodeServicePluginConfiguration | undefined = + await CoreConfigFiles._nodeServiceConfigurationLoader.tryLoadConfigurationFileForProjectAsync( + terminal, + projectPath, + rigConfig + ); + return configurationFile; } } diff --git a/apps/heft/tsconfig.json b/apps/heft/tsconfig.json index da04e9fff9d..0f045dd1bd2 100644 --- a/apps/heft/tsconfig.json +++ b/apps/heft/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "types": ["heft-jest", "node"], - "lib": ["ES2020"] + "lib": ["ES2020"], + "resolveJsonModule": true } }