From 70692174bb77dafca133f48491d367243b976422 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Jul 2026 18:59:26 -0700 Subject: [PATCH 1/4] Refactor private static members to module scope; enforce no-restricted-syntax rule Enforce the new ESLint rule in decoupled-local-node-rig that forbids `private static` methods and properties (kept at "error" severity alongside the existing `export *` restriction). Convert every `private static` violation across all rig-consuming projects to module-scoped functions and variables: - private static method -> module-scoped function - private static property -> module-scoped const (or let if reassigned) Edge cases handled without changing public behavior: - Helpers needing private instance members / private constructors kept as private instance methods, or return data for an in-class caller to construct (e.g. JsonSchema, LockFile, RigConfig, MinimalRushConfiguration). - Stateful singletons (e.g. EnvironmentConfiguration) moved to module state. - Async lazy-init caches suppressed with require-atomic-updates where needed. - Module declarations placed to preserve TSDoc/API-Extractor doc association and avoid no-use-before-define. - Test seams exported where tests referenced moved members. Full `rush build` passes with zero errors and zero warnings. --- .../src/nodes/CustomDocNodeKind.ts | 10 +- apps/api-documenter/src/utils/Utilities.ts | 5 +- .../src/analyzer/ExportAnalyzer.ts | 20 +- .../src/analyzer/PackageMetadataManager.ts | 58 +- .../src/analyzer/TypeScriptHelpers.ts | 18 +- apps/api-extractor/src/api/CompilerState.ts | 196 +-- apps/api-extractor/src/api/Extractor.ts | 385 +++--- apps/api-extractor/src/api/ExtractorConfig.ts | 387 +++--- apps/api-extractor/src/collector/Collector.ts | 100 +- .../src/collector/MessageRouter.ts | 118 +- .../src/collector/SourceMapper.ts | 76 +- .../src/enhancers/ValidationEnhancer.ts | 391 +++--- .../src/generators/ApiReportGenerator.ts | 707 ++++++----- .../DeclarationReferenceGenerator.ts | 181 ++- .../src/generators/DtsRollupGenerator.ts | 699 ++++++----- .../src/generators/ExcerptBuilder.ts | 383 +++--- .../configuration/HeftPluginConfiguration.ts | 12 +- apps/heft/src/plugins/NodeServicePlugin.ts | 6 +- apps/heft/src/utilities/CoreConfigFiles.ts | 30 +- .../src/LaunchOptionsValidator.ts | 12 +- apps/rundown/src/launcher.ts | 16 +- .../pluginFramework/RushMcpPluginLoader.ts | 31 +- .../src/utilities/command-runner.ts | 133 +- apps/rush/src/MinimalRushConfiguration.ts | 24 +- apps/rush/src/RushCommandSelector.ts | 52 +- .../src/DependencyAnalyzer.ts | 186 +-- .../src/PackletAnalyzer.ts | 6 +- eslint/eslint-plugin-packlets/src/Path.ts | 138 +-- .../src/test/Path.test.ts | 4 +- .../heft-jest-plugin/src/JestPlugin.ts | 486 ++++---- .../src/aedoc/AedocDefinitions.ts | 10 +- libraries/lookup-by-path/src/LookupByPath.ts | 68 +- libraries/node-core-library/src/Async.ts | 196 +-- libraries/node-core-library/src/Executable.ts | 531 ++++---- libraries/node-core-library/src/FileError.ts | 20 +- libraries/node-core-library/src/FileSystem.ts | 384 +++--- libraries/node-core-library/src/Import.ts | 58 +- .../node-core-library/src/InternalError.ts | 16 +- libraries/node-core-library/src/JsonFile.ts | 168 +-- libraries/node-core-library/src/JsonSchema.ts | 134 +-- libraries/node-core-library/src/LockFile.ts | 512 ++++---- .../src/PackageJsonLookup.ts | 10 +- .../node-core-library/src/PackageName.ts | 30 +- libraries/node-core-library/src/Path.ts | 24 +- .../src/SubprocessTerminator.ts | 173 ++- libraries/node-core-library/src/Text.ts | 14 +- libraries/node-core-library/src/TypeUuid.ts | 6 +- .../src/test/LockFile.test.ts | 10 +- .../package-extractor/src/PackageExtractor.ts | 62 +- libraries/rig-package/src/Helpers.ts | 9 +- libraries/rig-package/src/RigConfig.ts | 149 ++- .../src/api/ApprovedPackagesConfiguration.ts | 6 +- .../src/api/BuildCacheConfiguration.ts | 188 ++- .../rush-lib/src/api/CobuildConfiguration.ts | 80 +- .../src/api/CommandLineConfiguration.ts | 84 +- .../src/api/CommonVersionsConfiguration.ts | 71 +- .../src/api/CustomTipsConfiguration.ts | 6 +- .../src/api/EnvironmentConfiguration.ts | 310 +++-- libraries/rush-lib/src/api/Rush.ts | 69 +- .../rush-lib/src/api/RushConfiguration.ts | 237 ++-- .../src/api/RushPluginsConfiguration.ts | 6 +- .../src/api/RushProjectConfiguration.ts | 200 +-- .../rush-lib/src/api/RushUserConfiguration.ts | 9 +- .../src/api/SubspacesConfiguration.ts | 6 +- .../src/api/VersionPolicyConfiguration.ts | 9 +- .../src/api/test/RushConfiguration.test.ts | 2 +- .../src/cli/CommandLineMigrationAdvisor.ts | 58 +- .../rush-lib/src/cli/RushStartupBanner.ts | 32 +- .../rush-lib/src/cli/RushXCommandLine.ts | 419 ++++--- .../rush-lib/src/logic/ChangelogGenerator.ts | 204 ++-- .../rush-lib/src/logic/DependencyAnalyzer.ts | 14 +- .../rush-lib/src/logic/NodeJsCompatibility.ts | 68 +- .../rush-lib/src/logic/PublishUtilities.ts | 1067 ++++++++--------- libraries/rush-lib/src/logic/RepoStateFile.ts | 6 +- libraries/rush-lib/src/logic/SetupChecks.ts | 216 ++-- .../src/logic/StandardScriptUpdater.ts | 137 +-- .../src/logic/base/BaseLinkManager.ts | 154 +-- .../logic/buildCache/OperationBuildCache.ts | 77 +- .../deploy/DeployScenarioConfiguration.ts | 19 +- .../logic/installManager/InstallHelpers.ts | 96 +- .../src/logic/pnpm/PnpmfileConfiguration.ts | 111 +- .../pnpm/SubspacePnpmfileConfiguration.ts | 183 ++- .../logic/setup/ArtifactoryConfiguration.ts | 6 +- .../src/logic/setup/SetupPackageRegistry.ts | 130 +- .../rush-lib/src/logic/setup/TerminalInput.ts | 28 +- .../src/logic/test/PublishUtilities.test.ts | 8 +- .../versionMismatch/VersionMismatchFinder.ts | 116 +- .../src/logic/yarn/YarnShrinkwrapFile.ts | 103 +- .../pluginFramework/PluginLoader/RushSdk.ts | 8 +- .../src/utilities/PnpmSyncUtilities.ts | 28 +- .../rush-lib/src/utilities/RushAlerts.ts | 20 +- .../rush-lib/src/utilities/TarExecutable.ts | 30 +- libraries/rush-lib/src/utilities/Utilities.ts | 459 ++++--- libraries/rush-sdk/src/loader.ts | 56 +- libraries/rushell/src/ParseError.ts | 36 +- libraries/rushell/src/Tokenizer.ts | 20 +- libraries/terminal/src/AnsiEscape.ts | 174 ++- libraries/terminal/src/Colorize.ts | 88 +- libraries/tree-pattern/src/TreePattern.ts | 156 +-- .../src/parameters/BaseClasses.ts | 10 +- .../providers/CommandLineParameterProvider.ts | 6 +- .../src/cli/actions/ReadmeAction.ts | 12 +- .../includes/eslint/flat/profile/_common.js | 8 + .../src/logic/RushCommandWebViewPanel.ts | 13 +- .../src/logic/RushWorkspace.ts | 18 +- .../src/LoadThemedStylesLoader.ts | 12 +- .../src/AssetProcessor.ts | 457 ++++--- .../src/WebpackConfigurationUpdater.ts | 151 ++- 108 files changed, 6793 insertions(+), 6958 deletions(-) diff --git a/apps/api-documenter/src/nodes/CustomDocNodeKind.ts b/apps/api-documenter/src/nodes/CustomDocNodeKind.ts index 9d0af5fdab8..a022ac9dc28 100644 --- a/apps/api-documenter/src/nodes/CustomDocNodeKind.ts +++ b/apps/api-documenter/src/nodes/CustomDocNodeKind.ts @@ -22,11 +22,11 @@ export enum CustomDocNodeKind { TableRow = 'TableRow' } -export class CustomDocNodes { - private static _configuration: TSDocConfiguration | undefined; +let _configuration: TSDocConfiguration | undefined; +export class CustomDocNodes { public static get configuration(): TSDocConfiguration { - if (CustomDocNodes._configuration === undefined) { + if (_configuration === undefined) { const configuration: TSDocConfiguration = new TSDocConfiguration(); configuration.docNodeManager.registerDocNodes('@micrososft/api-documenter', [ @@ -53,8 +53,8 @@ export class CustomDocNodes { CustomDocNodeKind.EmphasisSpan ]); - CustomDocNodes._configuration = configuration; + _configuration = configuration; } - return CustomDocNodes._configuration; + return _configuration; } } diff --git a/apps/api-documenter/src/utils/Utilities.ts b/apps/api-documenter/src/utils/Utilities.ts index 2c9bc2556a6..56be69c2e03 100644 --- a/apps/api-documenter/src/utils/Utilities.ts +++ b/apps/api-documenter/src/utils/Utilities.ts @@ -3,8 +3,9 @@ import { ApiParameterListMixin, type ApiItem } from '@microsoft/api-extractor-model'; +const _badFilenameCharsRegExp: RegExp = /[^a-z0-9_\-\.]/gi; + export class Utilities { - private static readonly _badFilenameCharsRegExp: RegExp = /[^a-z0-9_\-\.]/gi; /** * Generates a concise signature for a function. Example: "getArea(width, height)" */ @@ -21,6 +22,6 @@ export class Utilities { public static getSafeFilenameForName(name: string): string { // TODO: This can introduce naming collisions. // We will fix that as part of https://github.com/microsoft/rushstack/issues/1308 - return name.replace(Utilities._badFilenameCharsRegExp, '_').toLowerCase(); + return name.replace(_badFilenameCharsRegExp, '_').toLowerCase(); } } diff --git a/apps/api-extractor/src/analyzer/ExportAnalyzer.ts b/apps/api-extractor/src/analyzer/ExportAnalyzer.ts index 0bc6236aa4d..6870d3a1954 100644 --- a/apps/api-extractor/src/analyzer/ExportAnalyzer.ts +++ b/apps/api-extractor/src/analyzer/ExportAnalyzer.ts @@ -657,7 +657,7 @@ export class ExportAnalyzer { importKind: AstImportKind.StarImport, exportName: declarationSymbol.name, modulePath: externalModulePath, - isTypeOnly: ExportAnalyzer._getIsTypeOnly(importDeclaration) + isTypeOnly: _getIsTypeOnly(importDeclaration) }); } @@ -690,7 +690,7 @@ export class ExportAnalyzer { importKind: AstImportKind.NamedImport, modulePath: externalModulePath, exportName: exportName, - isTypeOnly: ExportAnalyzer._getIsTypeOnly(importDeclaration) + isTypeOnly: _getIsTypeOnly(importDeclaration) }); } @@ -724,7 +724,7 @@ export class ExportAnalyzer { importKind: AstImportKind.DefaultImport, modulePath: externalModulePath, exportName, - isTypeOnly: ExportAnalyzer._getIsTypeOnly(importDeclaration) + isTypeOnly: _getIsTypeOnly(importDeclaration) }); } @@ -794,13 +794,6 @@ export class ExportAnalyzer { return namespaceImport; } - private static _getIsTypeOnly(importDeclaration: ts.ImportDeclaration): boolean { - if (importDeclaration.importClause) { - return !!importDeclaration.importClause.isTypeOnly; - } - return false; - } - private _getExportOfSpecifierAstModule( exportName: string, importOrExportDeclaration: ts.ImportDeclaration | ts.ExportDeclaration, @@ -1012,3 +1005,10 @@ export class ExportAnalyzer { return moduleSpecifier; } } + +function _getIsTypeOnly(importDeclaration: ts.ImportDeclaration): boolean { + if (importDeclaration.importClause) { + return !!importDeclaration.importClause.isTypeOnly; + } + return false; +} diff --git a/apps/api-extractor/src/analyzer/PackageMetadataManager.ts b/apps/api-extractor/src/analyzer/PackageMetadataManager.ts index 22b9be7e891..a007664d3c4 100644 --- a/apps/api-extractor/src/analyzer/PackageMetadataManager.ts +++ b/apps/api-extractor/src/analyzer/PackageMetadataManager.ts @@ -174,6 +174,33 @@ function _tryResolveTsdocMetadataFromMainField({ main }: INodePackageJson): stri } } +/** + * This feature is still being standardized: https://github.com/microsoft/tsdoc/issues/7 + * In the future we will use the @microsoft/tsdoc library to read this file. + */ +function _resolveTsdocMetadataPathFromPackageJson( + packageFolder: string, + packageJson: INodePackageJson +): string { + const tsdocMetadataRelativePath: string = + _tryResolveTsdocMetadataFromTsdocMetadataField(packageJson) ?? + _tryResolveTsdocMetadataFromExportsField(packageJson) ?? + _tryResolveTsdocMetadataFromTypesVersionsField(packageJson) ?? + _tryResolveTsdocMetadataFromTypesOrTypingsFields(packageJson) ?? + _tryResolveTsdocMetadataFromMainField(packageJson) ?? + // As a final fallback, place the file in the root of the package. + TSDOC_METADATA_FILENAME; + + // Always resolve relative to the package folder. + const tsdocMetadataPath: string = path.resolve( + packageFolder, + // This non-null assertion is safe because the last entry in TSDOC_METADATA_RESOLUTION_FUNCTIONS + // returns a non-undefined value. + tsdocMetadataRelativePath! + ); + return tsdocMetadataPath; +} + /** * This class maintains a cache of analyzed information obtained from package.json * files. It is built on top of the PackageJsonLookup class. @@ -202,33 +229,6 @@ export class PackageMetadataManager { this._messageRouter = messageRouter; } - /** - * This feature is still being standardized: https://github.com/microsoft/tsdoc/issues/7 - * In the future we will use the @microsoft/tsdoc library to read this file. - */ - private static _resolveTsdocMetadataPathFromPackageJson( - packageFolder: string, - packageJson: INodePackageJson - ): string { - const tsdocMetadataRelativePath: string = - _tryResolveTsdocMetadataFromTsdocMetadataField(packageJson) ?? - _tryResolveTsdocMetadataFromExportsField(packageJson) ?? - _tryResolveTsdocMetadataFromTypesVersionsField(packageJson) ?? - _tryResolveTsdocMetadataFromTypesOrTypingsFields(packageJson) ?? - _tryResolveTsdocMetadataFromMainField(packageJson) ?? - // As a final fallback, place the file in the root of the package. - TSDOC_METADATA_FILENAME; - - // Always resolve relative to the package folder. - const tsdocMetadataPath: string = path.resolve( - packageFolder, - // This non-null assertion is safe because the last entry in TSDOC_METADATA_RESOLUTION_FUNCTIONS - // returns a non-undefined value. - tsdocMetadataRelativePath! - ); - return tsdocMetadataPath; - } - /** * @param tsdocMetadataPath - An explicit path that can be configured in api-extractor.json. * If this parameter is not an empty string, it overrides the normal path calculation. @@ -243,7 +243,7 @@ export class PackageMetadataManager { return path.resolve(packageFolder, tsdocMetadataPath); } - return PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson(packageFolder, packageJson); + return _resolveTsdocMetadataPathFromPackageJson(packageFolder, packageJson); } /** @@ -292,7 +292,7 @@ export class PackageMetadataManager { let aedocSupported: boolean = false; - const tsdocMetadataPath: string = PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson( + const tsdocMetadataPath: string = _resolveTsdocMetadataPathFromPackageJson( packageJsonFolder, packageJson ); diff --git a/apps/api-extractor/src/analyzer/TypeScriptHelpers.ts b/apps/api-extractor/src/analyzer/TypeScriptHelpers.ts index 5c28b30d354..ccff983de02 100644 --- a/apps/api-extractor/src/analyzer/TypeScriptHelpers.ts +++ b/apps/api-extractor/src/analyzer/TypeScriptHelpers.ts @@ -10,15 +10,15 @@ import { InternalError } from '@rushstack/node-core-library'; import { SourceFileLocationFormatter } from './SourceFileLocationFormatter'; import { TypeScriptInternals } from './TypeScriptInternals'; -export class TypeScriptHelpers { - // Matches TypeScript's encoded names for well-known ECMAScript symbols like - // "__@iterator" or "__@toStringTag". - private static readonly _wellKnownSymbolNameRegExp: RegExp = /^__@(\w+)$/; +// Matches TypeScript's encoded names for well-known ECMAScript symbols like +// "__@iterator" or "__@toStringTag". +const _wellKnownSymbolNameRegExp: RegExp = /^__@(\w+)$/; - // Matches TypeScript's encoded names for late-bound symbols derived from `unique symbol` declarations - // which have the form of "__@@", i.e. "__@someSymbol@12345". - private static readonly _uniqueSymbolNameRegExp: RegExp = /^__@.*@\d+$/; +// Matches TypeScript's encoded names for late-bound symbols derived from `unique symbol` declarations +// which have the form of "__@@", i.e. "__@someSymbol@12345". +const _uniqueSymbolNameRegExp: RegExp = /^__@.*@\d+$/; +export class TypeScriptHelpers { /** * This traverses any symbol aliases to find the original place where an item was defined. * For example, suppose a class is defined as "export default class MyClass { }" @@ -268,7 +268,7 @@ export class TypeScriptHelpers { * If the string does not start with `__@` then `undefined` is returned. */ public static tryDecodeWellKnownSymbolName(name: ts.__String): string | undefined { - const match: RegExpExecArray | null = TypeScriptHelpers._wellKnownSymbolNameRegExp.exec(name as string); + const match: RegExpExecArray | null = _wellKnownSymbolNameRegExp.exec(name as string); if (match) { const identifier: string = match[1]; return `[Symbol.${identifier}]`; @@ -280,7 +280,7 @@ export class TypeScriptHelpers { * Returns whether the provided name was generated for a TypeScript `unique symbol`. */ public static isUniqueSymbolName(name: ts.__String): boolean { - return TypeScriptHelpers._uniqueSymbolNameRegExp.test(name as string); + return _uniqueSymbolNameRegExp.test(name as string); } /** diff --git a/apps/api-extractor/src/api/CompilerState.ts b/apps/api-extractor/src/api/CompilerState.ts index 7862c1c2e85..798718a25b8 100644 --- a/apps/api-extractor/src/api/CompilerState.ts +++ b/apps/api-extractor/src/api/CompilerState.ts @@ -83,9 +83,9 @@ export class CompilerState { } // Append the entry points and remove any non-declaration files from the list - const analysisFilePaths: string[] = CompilerState._generateFilePathsForAnalysis(inputFilePaths); + const analysisFilePaths: string[] = _generateFilePathsForAnalysis(inputFilePaths); - const compilerHost: ts.CompilerHost = CompilerState._createCompilerHost(commandLine, options); + const compilerHost: ts.CompilerHost = _createCompilerHost(commandLine, options); const program: ts.Program = ts.createProgram(analysisFilePaths, commandLine.options, compilerHost); @@ -98,114 +98,114 @@ export class CompilerState { program }); } +} - /** - * Given a list of absolute file paths, return a list containing only the declaration - * files. Duplicates are also eliminated. - * - * @remarks - * The tsconfig.json settings specify the compiler's input (a set of *.ts source files, - * plus some *.d.ts declaration files used for legacy typings). However API Extractor - * analyzes the compiler's output (a set of *.d.ts entry point files, plus any legacy - * typings). This requires API Extractor to generate a special file list when it invokes - * the compiler. - * - * Duplicates are removed so that entry points can be appended without worrying whether they - * may already appear in the tsconfig.json file list. - */ - private static _generateFilePathsForAnalysis(inputFilePaths: string[]): string[] { - const analysisFilePaths: string[] = []; +/** + * Given a list of absolute file paths, return a list containing only the declaration + * files. Duplicates are also eliminated. + * + * @remarks + * The tsconfig.json settings specify the compiler's input (a set of *.ts source files, + * plus some *.d.ts declaration files used for legacy typings). However API Extractor + * analyzes the compiler's output (a set of *.d.ts entry point files, plus any legacy + * typings). This requires API Extractor to generate a special file list when it invokes + * the compiler. + * + * Duplicates are removed so that entry points can be appended without worrying whether they + * may already appear in the tsconfig.json file list. + */ +function _generateFilePathsForAnalysis(inputFilePaths: string[]): string[] { + const analysisFilePaths: string[] = []; - const seenFiles: Set = new Set(); + const seenFiles: Set = new Set(); - for (const inputFilePath of inputFilePaths) { - const inputFileToUpper: string = inputFilePath.toUpperCase(); - if (!seenFiles.has(inputFileToUpper)) { - seenFiles.add(inputFileToUpper); + for (const inputFilePath of inputFilePaths) { + const inputFileToUpper: string = inputFilePath.toUpperCase(); + if (!seenFiles.has(inputFileToUpper)) { + seenFiles.add(inputFileToUpper); - if (!path.isAbsolute(inputFilePath)) { - throw new Error('Input file is not an absolute path: ' + inputFilePath); - } + if (!path.isAbsolute(inputFilePath)) { + throw new Error('Input file is not an absolute path: ' + inputFilePath); + } - if (ExtractorConfig.hasDtsFileExtension(inputFilePath)) { - analysisFilePaths.push(inputFilePath); - } + if (ExtractorConfig.hasDtsFileExtension(inputFilePath)) { + analysisFilePaths.push(inputFilePath); } } - - return analysisFilePaths; } - private static _createCompilerHost( - commandLine: ts.ParsedCommandLine, - options: IExtractorInvokeOptions | undefined - ): ts.CompilerHost { - // Create a default CompilerHost that we will override - const compilerHost: ts.CompilerHost = ts.createCompilerHost(commandLine.options); - - // Save a copy of the original members. Note that "compilerHost" cannot be the copy, because - // createCompilerHost() captures that instance in a closure that is used by the members. - const defaultCompilerHost: ts.CompilerHost = { ...compilerHost }; - - if (options && options.typescriptCompilerFolder) { - // Prevent a closure parameter - const typescriptCompilerLibFolder: string = path.join(options.typescriptCompilerFolder, 'lib'); - compilerHost.getDefaultLibLocation = () => typescriptCompilerLibFolder; - } + return analysisFilePaths; +} - // Used by compilerHost.fileExists() - // .d.ts file path --> whether the file exists - const dtsExistsCache: Map = new Map(); - - // Used by compilerHost.fileExists() - // Example: "c:/folder/file.part.ts" - const fileExtensionRegExp: RegExp = /^(.+)(\.[a-z0-9_]+)$/i; - - compilerHost.fileExists = (fileName: string): boolean => { - // In certain deprecated setups, the compiler may write its output files (.js and .d.ts) - // in the same folder as the corresponding input file (.ts or .tsx). When following imports, - // API Extractor wants to analyze the .d.ts file; however recent versions of the compiler engine - // will instead choose the .ts file. To work around this, we hook fileExists() to hide the - // existence of those files. - - // Is "fileName" a .d.ts file? The double extension ".d.ts" needs to be matched specially. - if (!ExtractorConfig.hasDtsFileExtension(fileName)) { - // It's not a .d.ts file. Is the file extension a potential source file? - const match: RegExpExecArray | null = fileExtensionRegExp.exec(fileName); - if (match) { - // Example: "c:/folder/file.part" - const pathWithoutExtension: string = match[1]; - // Example: ".ts" - const fileExtension: string = match[2]; - - switch (fileExtension.toLocaleLowerCase()) { - case '.ts': - case '.tsx': - case '.js': - case '.jsx': - // Yes, this is a possible source file. Is there a corresponding .d.ts file in the same folder? - const dtsFileName: string = `${pathWithoutExtension}.d.ts`; - - let dtsFileExists: boolean | undefined = dtsExistsCache.get(dtsFileName); - if (dtsFileExists === undefined) { - dtsFileExists = defaultCompilerHost.fileExists!(dtsFileName); - dtsExistsCache.set(dtsFileName, dtsFileExists); - } - - if (dtsFileExists) { - // fileName is a potential source file and a corresponding .d.ts file exists. - // Thus, API Extractor should ignore this file (so the .d.ts file will get analyzed instead). - return false; - } - break; - } +function _createCompilerHost( + commandLine: ts.ParsedCommandLine, + options: IExtractorInvokeOptions | undefined +): ts.CompilerHost { + // Create a default CompilerHost that we will override + const compilerHost: ts.CompilerHost = ts.createCompilerHost(commandLine.options); + + // Save a copy of the original members. Note that "compilerHost" cannot be the copy, because + // createCompilerHost() captures that instance in a closure that is used by the members. + const defaultCompilerHost: ts.CompilerHost = { ...compilerHost }; + + if (options && options.typescriptCompilerFolder) { + // Prevent a closure parameter + const typescriptCompilerLibFolder: string = path.join(options.typescriptCompilerFolder, 'lib'); + compilerHost.getDefaultLibLocation = () => typescriptCompilerLibFolder; + } + + // Used by compilerHost.fileExists() + // .d.ts file path --> whether the file exists + const dtsExistsCache: Map = new Map(); + + // Used by compilerHost.fileExists() + // Example: "c:/folder/file.part.ts" + const fileExtensionRegExp: RegExp = /^(.+)(\.[a-z0-9_]+)$/i; + + compilerHost.fileExists = (fileName: string): boolean => { + // In certain deprecated setups, the compiler may write its output files (.js and .d.ts) + // in the same folder as the corresponding input file (.ts or .tsx). When following imports, + // API Extractor wants to analyze the .d.ts file; however recent versions of the compiler engine + // will instead choose the .ts file. To work around this, we hook fileExists() to hide the + // existence of those files. + + // Is "fileName" a .d.ts file? The double extension ".d.ts" needs to be matched specially. + if (!ExtractorConfig.hasDtsFileExtension(fileName)) { + // It's not a .d.ts file. Is the file extension a potential source file? + const match: RegExpExecArray | null = fileExtensionRegExp.exec(fileName); + if (match) { + // Example: "c:/folder/file.part" + const pathWithoutExtension: string = match[1]; + // Example: ".ts" + const fileExtension: string = match[2]; + + switch (fileExtension.toLocaleLowerCase()) { + case '.ts': + case '.tsx': + case '.js': + case '.jsx': + // Yes, this is a possible source file. Is there a corresponding .d.ts file in the same folder? + const dtsFileName: string = `${pathWithoutExtension}.d.ts`; + + let dtsFileExists: boolean | undefined = dtsExistsCache.get(dtsFileName); + if (dtsFileExists === undefined) { + dtsFileExists = defaultCompilerHost.fileExists!(dtsFileName); + dtsExistsCache.set(dtsFileName, dtsFileExists); + } + + if (dtsFileExists) { + // fileName is a potential source file and a corresponding .d.ts file exists. + // Thus, API Extractor should ignore this file (so the .d.ts file will get analyzed instead). + return false; + } + break; } } + } - // Fall through to the default implementation - return defaultCompilerHost.fileExists!(fileName); - }; + // Fall through to the default implementation + return defaultCompilerHost.fileExists!(fileName); + }; - return compilerHost; - } + return compilerHost; } diff --git a/apps/api-extractor/src/api/Extractor.ts b/apps/api-extractor/src/api/Extractor.ts index 4542e9f8b60..c22d226551f 100644 --- a/apps/api-extractor/src/api/Extractor.ts +++ b/apps/api-extractor/src/api/Extractor.ts @@ -173,18 +173,14 @@ export class Extractor { * Returns the version number of the API Extractor NPM package. */ public static get version(): string { - return Extractor._getPackageJson().version; + return _getPackageJson().version; } /** * Returns the package name of the API Extractor NPM package. */ public static get packageName(): string { - return Extractor._getPackageJson().name; - } - - private static _getPackageJson(): IPackageJson { - return PackageJsonLookup.loadOwnPackageJson(__dirname); + return _getPackageJson().name; } /** @@ -253,7 +249,7 @@ export class Extractor { } } - this._checkCompilerCompatibility(extractorConfig, messageRouter); + _checkCompilerCompatibility(extractorConfig, messageRouter); if (messageRouter.showDiagnostics) { messageRouter.logDiagnostic(''); @@ -310,7 +306,7 @@ export class Extractor { } function writeApiReport(reportConfig: IExtractorConfigApiReport): boolean { - return Extractor._writeApiReport( + return _writeApiReport( collector, extractorConfig, messageRouter, @@ -330,30 +326,10 @@ export class Extractor { } if (rollupEnabled) { - Extractor._generateRollupDtsFile( - collector, - publicTrimmedFilePath, - DtsRollupKind.PublicRelease, - newlineKind - ); - Extractor._generateRollupDtsFile( - collector, - alphaTrimmedFilePath, - DtsRollupKind.AlphaRelease, - newlineKind - ); - Extractor._generateRollupDtsFile( - collector, - betaTrimmedFilePath, - DtsRollupKind.BetaRelease, - newlineKind - ); - Extractor._generateRollupDtsFile( - collector, - untrimmedFilePath, - DtsRollupKind.InternalRelease, - newlineKind - ); + _generateRollupDtsFile(collector, publicTrimmedFilePath, DtsRollupKind.PublicRelease, newlineKind); + _generateRollupDtsFile(collector, alphaTrimmedFilePath, DtsRollupKind.AlphaRelease, newlineKind); + _generateRollupDtsFile(collector, betaTrimmedFilePath, DtsRollupKind.BetaRelease, newlineKind); + _generateRollupDtsFile(collector, untrimmedFilePath, DtsRollupKind.InternalRelease, newlineKind); } if (tsdocMetadataEnabled) { @@ -383,202 +359,201 @@ export class Extractor { warningCount: messageRouter.warningCount }); } +} - /** - * Generates the API report at the specified release level, writes it to the specified file path, and compares - * the output to the existing report (if one exists). - * - * @param reportTempDirectoryPath - The path to the directory under which the temp report file will be written prior - * to comparison with an existing report. - * @param reportDirectoryPath - The path to the directory under which the existing report file is located, and to - * which the new report will be written post-comparison. - * @param reportConfig - API report configuration, including its file name and {@link ApiReportVariant}. - * @param printApiReportDiff - {@link IExtractorInvokeOptions.printApiReportDiff} - * - * @returns Whether or not the newly generated report differs from the existing report (if one exists). - */ - private static _writeApiReport( - collector: Collector, - extractorConfig: ExtractorConfig, - messageRouter: MessageRouter, - reportTempDirectoryPath: string, - reportDirectoryPath: string, - reportConfig: IExtractorConfigApiReport, - localBuild: boolean, - printApiReportDiff: boolean - ): boolean { - let apiReportChanged: boolean = false; - - const actualApiReportPath: string = path.resolve(reportTempDirectoryPath, reportConfig.fileName); - const actualApiReportShortPath: string = extractorConfig._getShortFilePath(actualApiReportPath); - - const expectedApiReportPath: string = path.resolve(reportDirectoryPath, reportConfig.fileName); - const expectedApiReportShortPath: string = extractorConfig._getShortFilePath(expectedApiReportPath); - - collector.messageRouter.logVerbose( - ConsoleMessageId.WritingApiReport, - `Generating ${reportConfig.variant} API report: ${expectedApiReportPath}` - ); - - const actualApiReportContent: string = ApiReportGenerator.generateReviewFileContent( - collector, - reportConfig.variant - ); +function _getPackageJson(): IPackageJson { + return PackageJsonLookup.loadOwnPackageJson(__dirname); +} - // Write the actual file - FileSystem.writeFile(actualApiReportPath, actualApiReportContent, { - ensureFolderExists: true, - convertLineEndings: extractorConfig.newlineKind +/** + * Generates the API report at the specified release level, writes it to the specified file path, and compares + * the output to the existing report (if one exists). + * + * @param reportTempDirectoryPath - The path to the directory under which the temp report file will be written prior + * to comparison with an existing report. + * @param reportDirectoryPath - The path to the directory under which the existing report file is located, and to + * which the new report will be written post-comparison. + * @param reportConfig - API report configuration, including its file name and {@link ApiReportVariant}. + * @param printApiReportDiff - {@link IExtractorInvokeOptions.printApiReportDiff} + * + * @returns Whether or not the newly generated report differs from the existing report (if one exists). + */ +function _writeApiReport( + collector: Collector, + extractorConfig: ExtractorConfig, + messageRouter: MessageRouter, + reportTempDirectoryPath: string, + reportDirectoryPath: string, + reportConfig: IExtractorConfigApiReport, + localBuild: boolean, + printApiReportDiff: boolean +): boolean { + let apiReportChanged: boolean = false; + + const actualApiReportPath: string = path.resolve(reportTempDirectoryPath, reportConfig.fileName); + const actualApiReportShortPath: string = extractorConfig._getShortFilePath(actualApiReportPath); + + const expectedApiReportPath: string = path.resolve(reportDirectoryPath, reportConfig.fileName); + const expectedApiReportShortPath: string = extractorConfig._getShortFilePath(expectedApiReportPath); + + collector.messageRouter.logVerbose( + ConsoleMessageId.WritingApiReport, + `Generating ${reportConfig.variant} API report: ${expectedApiReportPath}` + ); + + const actualApiReportContent: string = ApiReportGenerator.generateReviewFileContent( + collector, + reportConfig.variant + ); + + // Write the actual file + FileSystem.writeFile(actualApiReportPath, actualApiReportContent, { + ensureFolderExists: true, + convertLineEndings: extractorConfig.newlineKind + }); + + // Compare it against the expected file + if (FileSystem.exists(expectedApiReportPath)) { + const expectedApiReportContent: string = FileSystem.readFile(expectedApiReportPath, { + convertLineEndings: NewlineKind.Lf }); - // Compare it against the expected file - if (FileSystem.exists(expectedApiReportPath)) { - const expectedApiReportContent: string = FileSystem.readFile(expectedApiReportPath, { - convertLineEndings: NewlineKind.Lf - }); - - if ( - !ApiReportGenerator.areEquivalentApiFileContents(actualApiReportContent, expectedApiReportContent) - ) { - apiReportChanged = true; - - if (!localBuild) { - // For a production build, issue a warning that will break the CI build. - messageRouter.logWarning( - ConsoleMessageId.ApiReportNotCopied, - 'You have changed the API signature for this project.' + - ` Please copy the file "${actualApiReportShortPath}" to "${expectedApiReportShortPath}",` + - ` or perform a local build (which does this automatically).` + - ` See the Git repo documentation for more info.` - ); - } else { - // For a local build, just copy the file automatically. - messageRouter.logWarning( - ConsoleMessageId.ApiReportCopied, - `You have changed the API signature for this project. Updating ${expectedApiReportShortPath}` - ); - - FileSystem.writeFile(expectedApiReportPath, actualApiReportContent, { - ensureFolderExists: true, - convertLineEndings: extractorConfig.newlineKind - }); - } - - if (messageRouter.showVerboseMessages || printApiReportDiff) { - const Diff: typeof import('diff') = require('diff'); - const patch: import('diff').StructuredPatch = Diff.structuredPatch( - expectedApiReportShortPath, - actualApiReportShortPath, - expectedApiReportContent, - actualApiReportContent - ); - const logFunction: - | (typeof MessageRouter.prototype)['logWarning'] - | (typeof MessageRouter.prototype)['logVerbose'] = printApiReportDiff - ? messageRouter.logWarning.bind(messageRouter) - : messageRouter.logVerbose.bind(messageRouter); - - logFunction( - ConsoleMessageId.ApiReportDiff, - 'Changes to the API report:\n\n' + Diff.formatPatch(patch) - ); - } - } else { - messageRouter.logVerbose( - ConsoleMessageId.ApiReportUnchanged, - `The API report is up to date: ${actualApiReportShortPath}` - ); - } - } else { - // The target file does not exist, so we are setting up the API review file for the first time. - // - // NOTE: People sometimes make a mistake where they move a project and forget to update the "reportFolder" - // setting, which causes a new file to silently get written to the wrong place. This can be confusing. - // Thus we treat the initial creation of the file specially. + if (!ApiReportGenerator.areEquivalentApiFileContents(actualApiReportContent, expectedApiReportContent)) { apiReportChanged = true; if (!localBuild) { // For a production build, issue a warning that will break the CI build. messageRouter.logWarning( ConsoleMessageId.ApiReportNotCopied, - 'The API report file is missing.' + + 'You have changed the API signature for this project.' + ` Please copy the file "${actualApiReportShortPath}" to "${expectedApiReportShortPath}",` + ` or perform a local build (which does this automatically).` + ` See the Git repo documentation for more info.` ); } else { - const expectedApiReportFolder: string = path.dirname(expectedApiReportPath); - if (!FileSystem.exists(expectedApiReportFolder)) { - messageRouter.logError( - ConsoleMessageId.ApiReportFolderMissing, - 'Unable to create the API report file. Please make sure the target folder exists:\n' + - expectedApiReportFolder - ); - } else { - FileSystem.writeFile(expectedApiReportPath, actualApiReportContent, { - convertLineEndings: extractorConfig.newlineKind - }); - messageRouter.logWarning( - ConsoleMessageId.ApiReportCreated, - 'The API report file was missing, so a new file was created. Please add this file to Git:\n' + - expectedApiReportPath - ); - } + // For a local build, just copy the file automatically. + messageRouter.logWarning( + ConsoleMessageId.ApiReportCopied, + `You have changed the API signature for this project. Updating ${expectedApiReportShortPath}` + ); + + FileSystem.writeFile(expectedApiReportPath, actualApiReportContent, { + ensureFolderExists: true, + convertLineEndings: extractorConfig.newlineKind + }); + } + + if (messageRouter.showVerboseMessages || printApiReportDiff) { + const Diff: typeof import('diff') = require('diff'); + const patch: import('diff').StructuredPatch = Diff.structuredPatch( + expectedApiReportShortPath, + actualApiReportShortPath, + expectedApiReportContent, + actualApiReportContent + ); + const logFunction: + | (typeof MessageRouter.prototype)['logWarning'] + | (typeof MessageRouter.prototype)['logVerbose'] = printApiReportDiff + ? messageRouter.logWarning.bind(messageRouter) + : messageRouter.logVerbose.bind(messageRouter); + + logFunction( + ConsoleMessageId.ApiReportDiff, + 'Changes to the API report:\n\n' + Diff.formatPatch(patch) + ); + } + } else { + messageRouter.logVerbose( + ConsoleMessageId.ApiReportUnchanged, + `The API report is up to date: ${actualApiReportShortPath}` + ); + } + } else { + // The target file does not exist, so we are setting up the API review file for the first time. + // + // NOTE: People sometimes make a mistake where they move a project and forget to update the "reportFolder" + // setting, which causes a new file to silently get written to the wrong place. This can be confusing. + // Thus we treat the initial creation of the file specially. + apiReportChanged = true; + + if (!localBuild) { + // For a production build, issue a warning that will break the CI build. + messageRouter.logWarning( + ConsoleMessageId.ApiReportNotCopied, + 'The API report file is missing.' + + ` Please copy the file "${actualApiReportShortPath}" to "${expectedApiReportShortPath}",` + + ` or perform a local build (which does this automatically).` + + ` See the Git repo documentation for more info.` + ); + } else { + const expectedApiReportFolder: string = path.dirname(expectedApiReportPath); + if (!FileSystem.exists(expectedApiReportFolder)) { + messageRouter.logError( + ConsoleMessageId.ApiReportFolderMissing, + 'Unable to create the API report file. Please make sure the target folder exists:\n' + + expectedApiReportFolder + ); + } else { + FileSystem.writeFile(expectedApiReportPath, actualApiReportContent, { + convertLineEndings: extractorConfig.newlineKind + }); + messageRouter.logWarning( + ConsoleMessageId.ApiReportCreated, + 'The API report file was missing, so a new file was created. Please add this file to Git:\n' + + expectedApiReportPath + ); } } - return apiReportChanged; } + return apiReportChanged; +} - private static _checkCompilerCompatibility( - extractorConfig: ExtractorConfig, - messageRouter: MessageRouter - ): void { - messageRouter.logInfo( - ConsoleMessageId.Preamble, - `Analysis will use the bundled TypeScript version ${ts.version}` - ); +function _checkCompilerCompatibility(extractorConfig: ExtractorConfig, messageRouter: MessageRouter): void { + messageRouter.logInfo( + ConsoleMessageId.Preamble, + `Analysis will use the bundled TypeScript version ${ts.version}` + ); - try { - const typescriptPath: string = resolve.sync('typescript', { - basedir: extractorConfig.projectFolder, - preserveSymlinks: false - }); - const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup(); - const packageJson: INodePackageJson | undefined = - packageJsonLookup.tryLoadNodePackageJsonFor(typescriptPath); - if (packageJson && packageJson.version && semver.valid(packageJson.version)) { - // Consider a newer MINOR release to be incompatible - const ourMajor: number = semver.major(ts.version); - const ourMinor: number = semver.minor(ts.version); - - const theirMajor: number = semver.major(packageJson.version); - const theirMinor: number = semver.minor(packageJson.version); - - if (theirMajor > ourMajor || (theirMajor === ourMajor && theirMinor > ourMinor)) { - messageRouter.logInfo( - ConsoleMessageId.CompilerVersionNotice, - `*** The target project appears to use TypeScript ${packageJson.version} which is newer than the` + - ` bundled compiler engine; consider upgrading API Extractor.` - ); - } + try { + const typescriptPath: string = resolve.sync('typescript', { + basedir: extractorConfig.projectFolder, + preserveSymlinks: false + }); + const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup(); + const packageJson: INodePackageJson | undefined = + packageJsonLookup.tryLoadNodePackageJsonFor(typescriptPath); + if (packageJson && packageJson.version && semver.valid(packageJson.version)) { + // Consider a newer MINOR release to be incompatible + const ourMajor: number = semver.major(ts.version); + const ourMinor: number = semver.minor(ts.version); + + const theirMajor: number = semver.major(packageJson.version); + const theirMinor: number = semver.minor(packageJson.version); + + if (theirMajor > ourMajor || (theirMajor === ourMajor && theirMinor > ourMinor)) { + messageRouter.logInfo( + ConsoleMessageId.CompilerVersionNotice, + `*** The target project appears to use TypeScript ${packageJson.version} which is newer than the` + + ` bundled compiler engine; consider upgrading API Extractor.` + ); } - } catch (e) { - // The compiler detection heuristic is not expected to work in many configurations } + } catch (e) { + // The compiler detection heuristic is not expected to work in many configurations } +} - private static _generateRollupDtsFile( - collector: Collector, - outputPath: string, - dtsKind: DtsRollupKind, - newlineKind: NewlineKind - ): void { - if (outputPath !== '') { - collector.messageRouter.logVerbose( - ConsoleMessageId.WritingDtsRollup, - `Writing package typings: ${outputPath}` - ); - DtsRollupGenerator.writeTypingsFile(collector, outputPath, dtsKind, newlineKind); - } +function _generateRollupDtsFile( + collector: Collector, + outputPath: string, + dtsKind: DtsRollupKind, + newlineKind: NewlineKind +): void { + if (outputPath !== '') { + collector.messageRouter.logVerbose( + ConsoleMessageId.WritingDtsRollup, + `Writing package typings: ${outputPath}` + ); + DtsRollupGenerator.writeTypingsFile(collector, outputPath, dtsKind, newlineKind); } } diff --git a/apps/api-extractor/src/api/ExtractorConfig.ts b/apps/api-extractor/src/api/ExtractorConfig.ts index 92d15c944ac..f3e33f46825 100644 --- a/apps/api-extractor/src/api/ExtractorConfig.ts +++ b/apps/api-extractor/src/api/ExtractorConfig.ts @@ -228,6 +228,16 @@ interface IExtractorConfigParameters { enumMemberOrder: EnumMemberOrder; } +const _defaultConfig: Partial = JsonFile.load( + path.join(__dirname, '../schemas/api-extractor-defaults.json') +); + +/** + * Match all three flavors for type declaration files (.d.ts, .d.mts, .d.cts) + * including the new TS 5 bundle resolutions (.d.\{extension\}.ts, .d.\{extension\}.mts, .d.\{extension\}.cts) + **/ +const _declarationFileExtensionRegExp: RegExp = /\.d(\.[^./\\]+)?\.(c|m)?ts$/i; + /** * The `ExtractorConfig` class loads, validates, interprets, and represents the api-extractor.json config file. * @sealed @@ -254,16 +264,6 @@ export class ExtractorConfig { '../../extends/tsdoc-base.json' ); - private static readonly _defaultConfig: Partial = JsonFile.load( - path.join(__dirname, '../schemas/api-extractor-defaults.json') - ); - - /** - * Match all three flavors for type declaration files (.d.ts, .d.mts, .d.cts) - * including the new TS 5 bundle resolutions (.d.\{extension\}.ts, .d.\{extension\}.mts, .d.\{extension\}.cts) - **/ - private static readonly _declarationFileExtensionRegExp: RegExp = /\.d(\.[^./\\]+)?\.(c|m)?ts$/i; - /** {@inheritDoc IConfigFile.projectFolder} */ public readonly projectFolder: string; @@ -684,7 +684,7 @@ export class ExtractorConfig { // This step has to be performed in advance, since the currentConfigFolderPath information will be lost // after the merge is performed. - ExtractorConfig._resolveConfigFileRelativePaths(baseConfig, currentConfigFolderPath); + _resolveConfigFileRelativePaths(baseConfig, currentConfigFolderPath); // Merge extractorConfig into baseConfig, mutating baseConfig Objects.mergeWith(baseConfig, configObject, mergeCustomizer); @@ -698,7 +698,7 @@ export class ExtractorConfig { // Lastly, apply the defaults configObject = Objects.mergeWith( - structuredClone(ExtractorConfig._defaultConfig), + structuredClone(_defaultConfig), configObject, mergeCustomizer ) as Partial; @@ -709,121 +709,6 @@ export class ExtractorConfig { return configObject as IConfigFile; } - private static _resolveConfigFileRelativePaths( - configFile: IConfigFile, - currentConfigFolderPath: string - ): void { - if (configFile.projectFolder) { - configFile.projectFolder = ExtractorConfig._resolveConfigFileRelativePath( - 'projectFolder', - configFile.projectFolder, - currentConfigFolderPath - ); - } - - if (configFile.mainEntryPointFilePath) { - configFile.mainEntryPointFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'mainEntryPointFilePath', - configFile.mainEntryPointFilePath, - currentConfigFolderPath - ); - } - - if (configFile.compiler) { - if (configFile.compiler.tsconfigFilePath) { - configFile.compiler.tsconfigFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'tsconfigFilePath', - configFile.compiler.tsconfigFilePath, - currentConfigFolderPath - ); - } - } - - if (configFile.apiReport) { - if (configFile.apiReport.reportFolder) { - configFile.apiReport.reportFolder = ExtractorConfig._resolveConfigFileRelativePath( - 'reportFolder', - configFile.apiReport.reportFolder, - currentConfigFolderPath - ); - } - if (configFile.apiReport.reportTempFolder) { - configFile.apiReport.reportTempFolder = ExtractorConfig._resolveConfigFileRelativePath( - 'reportTempFolder', - configFile.apiReport.reportTempFolder, - currentConfigFolderPath - ); - } - } - - if (configFile.docModel) { - if (configFile.docModel.apiJsonFilePath) { - configFile.docModel.apiJsonFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'apiJsonFilePath', - configFile.docModel.apiJsonFilePath, - currentConfigFolderPath - ); - } - } - - if (configFile.dtsRollup) { - if (configFile.dtsRollup.untrimmedFilePath) { - configFile.dtsRollup.untrimmedFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'untrimmedFilePath', - configFile.dtsRollup.untrimmedFilePath, - currentConfigFolderPath - ); - } - if (configFile.dtsRollup.alphaTrimmedFilePath) { - configFile.dtsRollup.alphaTrimmedFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'alphaTrimmedFilePath', - configFile.dtsRollup.alphaTrimmedFilePath, - currentConfigFolderPath - ); - } - if (configFile.dtsRollup.betaTrimmedFilePath) { - configFile.dtsRollup.betaTrimmedFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'betaTrimmedFilePath', - configFile.dtsRollup.betaTrimmedFilePath, - currentConfigFolderPath - ); - } - if (configFile.dtsRollup.publicTrimmedFilePath) { - configFile.dtsRollup.publicTrimmedFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'publicTrimmedFilePath', - configFile.dtsRollup.publicTrimmedFilePath, - currentConfigFolderPath - ); - } - } - - if (configFile.tsdocMetadata) { - if (configFile.tsdocMetadata.tsdocMetadataFilePath) { - configFile.tsdocMetadata.tsdocMetadataFilePath = ExtractorConfig._resolveConfigFileRelativePath( - 'tsdocMetadataFilePath', - configFile.tsdocMetadata.tsdocMetadataFilePath, - currentConfigFolderPath - ); - } - } - } - - private static _resolveConfigFileRelativePath( - fieldName: string, - fieldValue: string, - currentConfigFolderPath: string - ): string { - if (!path.isAbsolute(fieldValue)) { - if (fieldValue.indexOf('') !== 0) { - // If the path is not absolute and does not start with "", then resolve it relative - // to the folder of the config file that it appears in - return path.join(currentConfigFolderPath, fieldValue); - } - } - - return fieldValue; - } - /** * Prepares an `ExtractorConfig` object using a configuration that is provided as a runtime object, * rather than reading it from disk. This allows configurations to be constructed programmatically, @@ -928,7 +813,7 @@ export class ExtractorConfig { } } } else { - ExtractorConfig._rejectAnyTokensInPath(configObject.projectFolder, 'projectFolder'); + _rejectAnyTokensInPath(configObject.projectFolder, 'projectFolder'); if (!FileSystem.exists(configObject.projectFolder)) { throw new Error('The specified "projectFolder" path does not exist: ' + configObject.projectFolder); @@ -952,7 +837,7 @@ export class ExtractorConfig { // A merged configuration should have this throw new Error('The "mainEntryPointFilePath" setting is missing'); } - const mainEntryPointFilePath: string = ExtractorConfig._resolvePathWithTokens( + const mainEntryPointFilePath: string = _resolvePathWithTokens( 'mainEntryPointFilePath', configObject.mainEntryPointFilePath, tokenContext @@ -973,7 +858,7 @@ export class ExtractorConfig { // Note: we cannot fully validate package name patterns, as the strings may contain wildcards. // We won't know if the entries are valid until we can compare them against the package.json "dependencies" contents. - const tsconfigFilePath: string = ExtractorConfig._resolvePathWithTokens( + const tsconfigFilePath: string = _resolvePathWithTokens( 'tsconfigFilePath', configObject.compiler.tsconfigFilePath, tokenContext @@ -1039,7 +924,7 @@ export class ExtractorConfig { const fileNameWithTokens: string = `${reportFileNameBase}${ reportVariantKind === 'complete' ? '' : `.${reportVariantKind}` }${reportFileNameSuffix}`; - const normalizedFileName: string = ExtractorConfig._expandStringWithTokens( + const normalizedFileName: string = _expandStringWithTokens( 'reportFileName', fileNameWithTokens, tokenContext @@ -1052,15 +937,11 @@ export class ExtractorConfig { } if (apiReportConfig.reportFolder) { - reportFolder = ExtractorConfig._resolvePathWithTokens( - 'reportFolder', - apiReportConfig.reportFolder, - tokenContext - ); + reportFolder = _resolvePathWithTokens('reportFolder', apiReportConfig.reportFolder, tokenContext); } if (apiReportConfig.reportTempFolder) { - reportTempFolder = ExtractorConfig._resolvePathWithTokens( + reportTempFolder = _resolvePathWithTokens( 'reportTempFolder', apiReportConfig.reportTempFolder, tokenContext @@ -1078,7 +959,7 @@ export class ExtractorConfig { let docModelIncludeForgottenExports: boolean = false; let projectFolderUrl: string | undefined; if (configObject.docModel?.enabled) { - apiJsonFilePath = ExtractorConfig._resolvePathWithTokens( + apiJsonFilePath = _resolvePathWithTokens( 'apiJsonFilePath', configObject.docModel.apiJsonFilePath, tokenContext @@ -1150,7 +1031,7 @@ export class ExtractorConfig { packageJson ); } else { - tsdocMetadataFilePath = ExtractorConfig._resolvePathWithTokens( + tsdocMetadataFilePath = _resolvePathWithTokens( 'tsdocMetadataFilePath', configObject.tsdocMetadata.tsdocMetadataFilePath, tokenContext @@ -1175,22 +1056,22 @@ export class ExtractorConfig { if (configObject.dtsRollup) { rollupEnabled = !!configObject.dtsRollup.enabled; - untrimmedFilePath = ExtractorConfig._resolvePathWithTokens( + untrimmedFilePath = _resolvePathWithTokens( 'untrimmedFilePath', configObject.dtsRollup.untrimmedFilePath, tokenContext ); - alphaTrimmedFilePath = ExtractorConfig._resolvePathWithTokens( + alphaTrimmedFilePath = _resolvePathWithTokens( 'alphaTrimmedFilePath', configObject.dtsRollup.alphaTrimmedFilePath, tokenContext ); - betaTrimmedFilePath = ExtractorConfig._resolvePathWithTokens( + betaTrimmedFilePath = _resolvePathWithTokens( 'betaTrimmedFilePath', configObject.dtsRollup.betaTrimmedFilePath, tokenContext ); - publicTrimmedFilePath = ExtractorConfig._resolvePathWithTokens( + publicTrimmedFilePath = _resolvePathWithTokens( 'publicTrimmedFilePath', configObject.dtsRollup.publicTrimmedFilePath, tokenContext @@ -1290,74 +1171,186 @@ export class ExtractorConfig { return this.reportConfigs.find((x) => x.variant === 'complete'); } - private static _resolvePathWithTokens( - fieldName: string, - value: string | undefined, - tokenContext: IExtractorConfigTokenContext - ): string { - value = ExtractorConfig._expandStringWithTokens(fieldName, value, tokenContext); - if (value !== '') { - value = path.resolve(tokenContext.projectFolder, value); + /** + * Returns true if the specified file path has the ".d.ts" file extension. + */ + public static hasDtsFileExtension(filePath: string): boolean { + return _declarationFileExtensionRegExp.test(filePath); + } +} + +function _resolveConfigFileRelativePaths(configFile: IConfigFile, currentConfigFolderPath: string): void { + if (configFile.projectFolder) { + configFile.projectFolder = _resolveConfigFileRelativePath( + 'projectFolder', + configFile.projectFolder, + currentConfigFolderPath + ); + } + + if (configFile.mainEntryPointFilePath) { + configFile.mainEntryPointFilePath = _resolveConfigFileRelativePath( + 'mainEntryPointFilePath', + configFile.mainEntryPointFilePath, + currentConfigFolderPath + ); + } + + if (configFile.compiler) { + if (configFile.compiler.tsconfigFilePath) { + configFile.compiler.tsconfigFilePath = _resolveConfigFileRelativePath( + 'tsconfigFilePath', + configFile.compiler.tsconfigFilePath, + currentConfigFolderPath + ); } - return value; } - private static _expandStringWithTokens( - fieldName: string, - value: string | undefined, - tokenContext: IExtractorConfigTokenContext - ): string { - value = value ? value.trim() : ''; - if (value !== '') { - value = Text.replaceAll(value, '', tokenContext.unscopedPackageName); - value = Text.replaceAll(value, '', tokenContext.packageName); - - const projectFolderToken: string = ''; - if (value.indexOf(projectFolderToken) === 0) { - // Replace "" at the start of a string - value = path.join(tokenContext.projectFolder, value.substr(projectFolderToken.length)); - } + if (configFile.apiReport) { + if (configFile.apiReport.reportFolder) { + configFile.apiReport.reportFolder = _resolveConfigFileRelativePath( + 'reportFolder', + configFile.apiReport.reportFolder, + currentConfigFolderPath + ); + } + if (configFile.apiReport.reportTempFolder) { + configFile.apiReport.reportTempFolder = _resolveConfigFileRelativePath( + 'reportTempFolder', + configFile.apiReport.reportTempFolder, + currentConfigFolderPath + ); + } + } - if (value.indexOf(projectFolderToken) >= 0) { - // If after all replacements, "" appears somewhere in the string, report an error - throw new Error( - `The "${fieldName}" value incorrectly uses the "" token.` + - ` It must appear at the start of the string.` - ); - } + if (configFile.docModel) { + if (configFile.docModel.apiJsonFilePath) { + configFile.docModel.apiJsonFilePath = _resolveConfigFileRelativePath( + 'apiJsonFilePath', + configFile.docModel.apiJsonFilePath, + currentConfigFolderPath + ); + } + } - if (value.indexOf('') >= 0) { - throw new Error(`The "${fieldName}" value incorrectly uses the "" token`); - } - ExtractorConfig._rejectAnyTokensInPath(value, fieldName); + if (configFile.dtsRollup) { + if (configFile.dtsRollup.untrimmedFilePath) { + configFile.dtsRollup.untrimmedFilePath = _resolveConfigFileRelativePath( + 'untrimmedFilePath', + configFile.dtsRollup.untrimmedFilePath, + currentConfigFolderPath + ); + } + if (configFile.dtsRollup.alphaTrimmedFilePath) { + configFile.dtsRollup.alphaTrimmedFilePath = _resolveConfigFileRelativePath( + 'alphaTrimmedFilePath', + configFile.dtsRollup.alphaTrimmedFilePath, + currentConfigFolderPath + ); + } + if (configFile.dtsRollup.betaTrimmedFilePath) { + configFile.dtsRollup.betaTrimmedFilePath = _resolveConfigFileRelativePath( + 'betaTrimmedFilePath', + configFile.dtsRollup.betaTrimmedFilePath, + currentConfigFolderPath + ); + } + if (configFile.dtsRollup.publicTrimmedFilePath) { + configFile.dtsRollup.publicTrimmedFilePath = _resolveConfigFileRelativePath( + 'publicTrimmedFilePath', + configFile.dtsRollup.publicTrimmedFilePath, + currentConfigFolderPath + ); } - return value; } - /** - * Returns true if the specified file path has the ".d.ts" file extension. - */ - public static hasDtsFileExtension(filePath: string): boolean { - return ExtractorConfig._declarationFileExtensionRegExp.test(filePath); + if (configFile.tsdocMetadata) { + if (configFile.tsdocMetadata.tsdocMetadataFilePath) { + configFile.tsdocMetadata.tsdocMetadataFilePath = _resolveConfigFileRelativePath( + 'tsdocMetadataFilePath', + configFile.tsdocMetadata.tsdocMetadataFilePath, + currentConfigFolderPath + ); + } } +} - /** - * Given a path string that may have originally contained expandable tokens such as `"` - * this reports an error if any token-looking substrings remain after expansion (e.g. `c:\blah\\blah`). - */ - private static _rejectAnyTokensInPath(value: string, fieldName: string): void { - if (value.indexOf('<') < 0 && value.indexOf('>') < 0) { - return; +function _resolveConfigFileRelativePath( + fieldName: string, + fieldValue: string, + currentConfigFolderPath: string +): string { + if (!path.isAbsolute(fieldValue)) { + if (fieldValue.indexOf('') !== 0) { + // If the path is not absolute and does not start with "", then resolve it relative + // to the folder of the config file that it appears in + return path.join(currentConfigFolderPath, fieldValue); + } + } + + return fieldValue; +} + +function _resolvePathWithTokens( + fieldName: string, + value: string | undefined, + tokenContext: IExtractorConfigTokenContext +): string { + value = _expandStringWithTokens(fieldName, value, tokenContext); + if (value !== '') { + value = path.resolve(tokenContext.projectFolder, value); + } + return value; +} + +function _expandStringWithTokens( + fieldName: string, + value: string | undefined, + tokenContext: IExtractorConfigTokenContext +): string { + value = value ? value.trim() : ''; + if (value !== '') { + value = Text.replaceAll(value, '', tokenContext.unscopedPackageName); + value = Text.replaceAll(value, '', tokenContext.packageName); + + const projectFolderToken: string = ''; + if (value.indexOf(projectFolderToken) === 0) { + // Replace "" at the start of a string + value = path.join(tokenContext.projectFolder, value.substr(projectFolderToken.length)); + } + + if (value.indexOf(projectFolderToken) >= 0) { + // If after all replacements, "" appears somewhere in the string, report an error + throw new Error( + `The "${fieldName}" value incorrectly uses the "" token.` + + ` It must appear at the start of the string.` + ); } - // Can we determine the name of a token? - const tokenRegExp: RegExp = /(\<[^<]*?\>)/; - const match: RegExpExecArray | null = tokenRegExp.exec(value); - if (match) { - throw new Error(`The "${fieldName}" value contains an unrecognized token "${match[1]}"`); + if (value.indexOf('') >= 0) { + throw new Error(`The "${fieldName}" value incorrectly uses the "" token`); } - throw new Error(`The "${fieldName}" value contains extra token characters ("<" or ">"): ${value}`); + _rejectAnyTokensInPath(value, fieldName); + } + return value; +} + +/** + * Given a path string that may have originally contained expandable tokens such as `"` + * this reports an error if any token-looking substrings remain after expansion (e.g. `c:\blah\\blah`). + */ +function _rejectAnyTokensInPath(value: string, fieldName: string): void { + if (value.indexOf('<') < 0 && value.indexOf('>') < 0) { + return; + } + + // Can we determine the name of a token? + const tokenRegExp: RegExp = /(\<[^<]*?\>)/; + const match: RegExpExecArray | null = tokenRegExp.exec(value); + if (match) { + throw new Error(`The "${fieldName}" value contains an unrecognized token "${match[1]}"`); } + throw new Error(`The "${fieldName}" value contains extra token characters ("<" or ">"): ${value}`); } const releaseTags: Set = new Set(['@public', '@alpha', '@beta', '@internal']); diff --git a/apps/api-extractor/src/collector/Collector.ts b/apps/api-extractor/src/collector/Collector.ts index 131257996e2..2e6a374f11d 100644 --- a/apps/api-extractor/src/collector/Collector.ts +++ b/apps/api-extractor/src/collector/Collector.ts @@ -141,7 +141,7 @@ export class Collector { this._tsdocParser = new tsdoc.TSDocParser(this.extractorConfig.tsdocConfiguration); // Resolve package name patterns and store concrete set of bundled package dependency names - this.bundledPackageNames = Collector._resolveBundledPackagePatterns( + this.bundledPackageNames = _resolveBundledPackagePatterns( this.extractorConfig.bundledPackages, this.extractorConfig.packageJson ); @@ -158,55 +158,6 @@ export class Collector { this._cachedOverloadIndexesByDeclaration = new Map(); } - /** - * Resolve provided `bundledPackages` names and glob patterns to a list of explicit package names. - * - * @remarks - * Explicit package names will be included in the output unconditionally. However, wildcard patterns will - * only be matched against the various dependencies listed in the provided package.json (if there was one). - * Patterns will be matched against `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`. - * - * @param bundledPackages - The list of package names and/or glob patterns to resolve. - * @param packageJson - The package.json of the package being processed (if there is one). - * @returns The set of resolved package names to be bundled during analysis. - */ - private static _resolveBundledPackagePatterns( - bundledPackages: string[], - packageJson: INodePackageJson | undefined - ): ReadonlySet { - if (bundledPackages.length === 0) { - // If no `bundledPackages` were specified, then there is nothing to resolve. - // Return an empty set. - return new Set(); - } - - // Accumulate all declared dependencies. - // Any wildcard patterns in `bundledPackages` will be resolved against these. - const dependencyNames: Set = new Set(); - Object.keys(packageJson?.dependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); - Object.keys(packageJson?.devDependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); - Object.keys(packageJson?.peerDependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); - Object.keys(packageJson?.optionalDependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); - - // The set of resolved package names to be populated and returned - const resolvedPackageNames: Set = new Set(); - - for (const packageNameOrPattern of bundledPackages) { - // If the string is an exact package name, use it regardless of package.json contents - if (PackageName.isValidName(packageNameOrPattern)) { - resolvedPackageNames.add(packageNameOrPattern); - } else { - // If the entry isn't an exact package name, assume glob pattern and search for matches - for (const dependencyName of dependencyNames) { - if (minimatch(dependencyName, packageNameOrPattern)) { - resolvedPackageNames.add(dependencyName); - } - } - } - } - return resolvedPackageNames; - } - /**a * Returns a list of names (e.g. "example-library") that should appear in a reference like this: * @@ -1096,3 +1047,52 @@ export class Collector { } } } + +/** + * Resolve provided `bundledPackages` names and glob patterns to a list of explicit package names. + * + * @remarks + * Explicit package names will be included in the output unconditionally. However, wildcard patterns will + * only be matched against the various dependencies listed in the provided package.json (if there was one). + * Patterns will be matched against `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`. + * + * @param bundledPackages - The list of package names and/or glob patterns to resolve. + * @param packageJson - The package.json of the package being processed (if there is one). + * @returns The set of resolved package names to be bundled during analysis. + */ +function _resolveBundledPackagePatterns( + bundledPackages: string[], + packageJson: INodePackageJson | undefined +): ReadonlySet { + if (bundledPackages.length === 0) { + // If no `bundledPackages` were specified, then there is nothing to resolve. + // Return an empty set. + return new Set(); + } + + // Accumulate all declared dependencies. + // Any wildcard patterns in `bundledPackages` will be resolved against these. + const dependencyNames: Set = new Set(); + Object.keys(packageJson?.dependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); + Object.keys(packageJson?.devDependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); + Object.keys(packageJson?.peerDependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); + Object.keys(packageJson?.optionalDependencies ?? {}).forEach((dep) => dependencyNames.add(dep)); + + // The set of resolved package names to be populated and returned + const resolvedPackageNames: Set = new Set(); + + for (const packageNameOrPattern of bundledPackages) { + // If the string is an exact package name, use it regardless of package.json contents + if (PackageName.isValidName(packageNameOrPattern)) { + resolvedPackageNames.add(packageNameOrPattern); + } else { + // If the entry isn't an exact package name, assume glob pattern and search for matches + for (const dependencyName of dependencyNames) { + if (minimatch(dependencyName, packageNameOrPattern)) { + resolvedPackageNames.add(dependencyName); + } + } + } + } + return resolvedPackageNames; +} diff --git a/apps/api-extractor/src/collector/MessageRouter.ts b/apps/api-extractor/src/collector/MessageRouter.ts index e35aa9571ea..fe13466fe31 100644 --- a/apps/api-extractor/src/collector/MessageRouter.ts +++ b/apps/api-extractor/src/collector/MessageRouter.ts @@ -116,7 +116,7 @@ export class MessageRouter { private _applyMessagesConfig(messagesConfig: IExtractorMessagesConfig): void { if (messagesConfig.compilerMessageReporting) { for (const messageId of Object.getOwnPropertyNames(messagesConfig.compilerMessageReporting)) { - const reportingRule: IReportingRule = MessageRouter._getNormalizedRule( + const reportingRule: IReportingRule = _getNormalizedRule( messagesConfig.compilerMessageReporting[messageId] ); @@ -135,7 +135,7 @@ export class MessageRouter { if (messagesConfig.extractorMessageReporting) { for (const messageId of Object.getOwnPropertyNames(messagesConfig.extractorMessageReporting)) { - const reportingRule: IReportingRule = MessageRouter._getNormalizedRule( + const reportingRule: IReportingRule = _getNormalizedRule( messagesConfig.extractorMessageReporting[messageId] ); @@ -159,7 +159,7 @@ export class MessageRouter { if (messagesConfig.tsdocMessageReporting) { for (const messageId of Object.getOwnPropertyNames(messagesConfig.tsdocMessageReporting)) { - const reportingRule: IReportingRule = MessageRouter._getNormalizedRule( + const reportingRule: IReportingRule = _getNormalizedRule( messagesConfig.tsdocMessageReporting[messageId] ); @@ -182,13 +182,6 @@ export class MessageRouter { } } - private static _getNormalizedRule(rule: IConfigMessageReportingRule): IReportingRule { - return { - logLevel: rule.logLevel || 'none', - addToApiReportFile: rule.addToApiReportFile || false - }; - } - public get messages(): ReadonlyArray { return this._messages; } @@ -303,55 +296,7 @@ export class MessageRouter { const keyNamesToOmit: Set = new Set(options.keyNamesToOmit); - return MessageRouter._buildJsonDumpObject(input, keyNamesToOmit); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static _buildJsonDumpObject(input: any, keyNamesToOmit: Set): any | undefined { - if (input === null || input === undefined) { - return null; // JSON uses null instead of undefined - } - - switch (typeof input) { - case 'boolean': - case 'number': - case 'string': - return input; - case 'object': - if (Array.isArray(input)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const outputArray: any[] = []; - for (const element of input) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const serializedElement: any = MessageRouter._buildJsonDumpObject(element, keyNamesToOmit); - if (serializedElement !== undefined) { - outputArray.push(serializedElement); - } - } - return outputArray; - } - - const outputObject: object = {}; - for (const key of Object.getOwnPropertyNames(input)) { - if (keyNamesToOmit.has(key)) { - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const value: any = input[key]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const serializedValue: any = MessageRouter._buildJsonDumpObject(value, keyNamesToOmit); - - if (serializedValue !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (outputObject as any)[key] = serializedValue; - } - } - return outputObject; - } - - return undefined; + return _buildJsonDumpObject(input, keyNamesToOmit); } /** @@ -666,3 +611,58 @@ export class MessageRouter { }); } } + +function _getNormalizedRule(rule: IConfigMessageReportingRule): IReportingRule { + return { + logLevel: rule.logLevel || 'none', + addToApiReportFile: rule.addToApiReportFile || false + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function _buildJsonDumpObject(input: any, keyNamesToOmit: Set): any | undefined { + if (input === null || input === undefined) { + return null; // JSON uses null instead of undefined + } + + switch (typeof input) { + case 'boolean': + case 'number': + case 'string': + return input; + case 'object': + if (Array.isArray(input)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const outputArray: any[] = []; + for (const element of input) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serializedElement: any = _buildJsonDumpObject(element, keyNamesToOmit); + if (serializedElement !== undefined) { + outputArray.push(serializedElement); + } + } + return outputArray; + } + + const outputObject: object = {}; + for (const key of Object.getOwnPropertyNames(input)) { + if (keyNamesToOmit.has(key)) { + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const value: any = input[key]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serializedValue: any = _buildJsonDumpObject(value, keyNamesToOmit); + + if (serializedValue !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (outputObject as any)[key] = serializedValue; + } + } + return outputObject; + } + + return undefined; +} diff --git a/apps/api-extractor/src/collector/SourceMapper.ts b/apps/api-extractor/src/collector/SourceMapper.ts index 368ea15fa07..d0859a4f395 100644 --- a/apps/api-extractor/src/collector/SourceMapper.ts +++ b/apps/api-extractor/src/collector/SourceMapper.ts @@ -13,7 +13,7 @@ interface ISourceMap { // SourceMapConsumer.originalPositionFor() is useless because the mapping contains numerous gaps, // and the API provides no way to find the nearest match. So instead we extract all the mapping items - // and search them using SourceMapper._findNearestMappingItem(). + // and search them using _findNearestMappingItem(). mappingItems: MappingItem[]; } @@ -105,13 +105,10 @@ export class SourceMapper { const sourceMap: ISourceMap | null = this._getSourceMap(sourceFilePath); if (!sourceMap) return; - const nearestMappingItem: MappingItem | undefined = SourceMapper._findNearestMappingItem( - sourceMap.mappingItems, - { - line: sourceFileLine, - column: sourceFileColumn - } - ); + const nearestMappingItem: MappingItem | undefined = _findNearestMappingItem(sourceMap.mappingItems, { + line: sourceFileLine, + column: sourceFileColumn + }); if (!nearestMappingItem) return; @@ -222,46 +219,43 @@ export class SourceMapper { return sourceMap; } +} - // The `mappingItems` array is sorted by generatedLine/generatedColumn (GENERATED_ORDER). - // The _findNearestMappingItem() lookup is a simple binary search that returns the previous item - // if there is no exact match. - private static _findNearestMappingItem( - mappingItems: MappingItem[], - position: Position - ): MappingItem | undefined { - if (mappingItems.length === 0) { - return undefined; - } +// The `mappingItems` array is sorted by generatedLine/generatedColumn (GENERATED_ORDER). +// The _findNearestMappingItem() lookup is a simple binary search that returns the previous item +// if there is no exact match. +function _findNearestMappingItem(mappingItems: MappingItem[], position: Position): MappingItem | undefined { + if (mappingItems.length === 0) { + return undefined; + } - let startIndex: number = 0; - let endIndex: number = mappingItems.length - 1; + let startIndex: number = 0; + let endIndex: number = mappingItems.length - 1; - while (startIndex <= endIndex) { - const middleIndex: number = startIndex + Math.floor((endIndex - startIndex) / 2); + while (startIndex <= endIndex) { + const middleIndex: number = startIndex + Math.floor((endIndex - startIndex) / 2); - const diff: number = SourceMapper._compareMappingItem(mappingItems[middleIndex], position); + const diff: number = _compareMappingItem(mappingItems[middleIndex], position); - if (diff < 0) { - startIndex = middleIndex + 1; - } else if (diff > 0) { - endIndex = middleIndex - 1; - } else { - // Exact match - return mappingItems[middleIndex]; - } + if (diff < 0) { + startIndex = middleIndex + 1; + } else if (diff > 0) { + endIndex = middleIndex - 1; + } else { + // Exact match + return mappingItems[middleIndex]; } - - // If we didn't find an exact match, then endIndex < startIndex. - // Take endIndex because it's the smaller value. - return mappingItems[endIndex]; } - private static _compareMappingItem(mappingItem: MappingItem, position: Position): number { - const diff: number = mappingItem.generatedLine - position.line; - if (diff !== 0) { - return diff; - } - return mappingItem.generatedColumn - position.column; + // If we didn't find an exact match, then endIndex < startIndex. + // Take endIndex because it's the smaller value. + return mappingItems[endIndex]; +} + +function _compareMappingItem(mappingItem: MappingItem, position: Position): number { + const diff: number = mappingItem.generatedLine - position.line; + if (diff !== 0) { + return diff; } + return mappingItem.generatedColumn - position.column; } diff --git a/apps/api-extractor/src/enhancers/ValidationEnhancer.ts b/apps/api-extractor/src/enhancers/ValidationEnhancer.ts index 09d80f862cc..31d4862748f 100644 --- a/apps/api-extractor/src/enhancers/ValidationEnhancer.ts +++ b/apps/api-extractor/src/enhancers/ValidationEnhancer.ts @@ -39,12 +39,12 @@ export class ValidationEnhancer { const astSymbol: AstSymbol = entity.astEntity; astSymbol.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => { - ValidationEnhancer._checkReferences(collector, astDeclaration, alreadyWarnedEntities); + _checkReferences(collector, astDeclaration, alreadyWarnedEntities); }); const symbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol); - ValidationEnhancer._checkForInternalUnderscore(collector, entity, astSymbol, symbolMetadata); - ValidationEnhancer._checkForInconsistentReleaseTags(collector, astSymbol, symbolMetadata); + _checkForInternalUnderscore(collector, entity, astSymbol, symbolMetadata); + _checkForInconsistentReleaseTags(collector, astSymbol, symbolMetadata); } else if (entity.astEntity instanceof AstNamespaceImport) { // A namespace created using "import * as ___ from ___" const astNamespaceImport: AstNamespaceImport = entity.astEntity; @@ -57,248 +57,245 @@ export class ValidationEnhancer { const astSymbol: AstSymbol = namespaceMemberAstEntity; astSymbol.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => { - ValidationEnhancer._checkReferences(collector, astDeclaration, alreadyWarnedEntities); + _checkReferences(collector, astDeclaration, alreadyWarnedEntities); }); const symbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol); - // (Don't apply ValidationEnhancer._checkForInternalUnderscore() for AstNamespaceImport members) + // (Don't apply _checkForInternalUnderscore() for AstNamespaceImport members) - ValidationEnhancer._checkForInconsistentReleaseTags(collector, astSymbol, symbolMetadata); + _checkForInconsistentReleaseTags(collector, astSymbol, symbolMetadata); } } } } } +} - private static _checkForInternalUnderscore( - collector: Collector, - collectorEntity: CollectorEntity, - astSymbol: AstSymbol, - symbolMetadata: SymbolMetadata - ): void { - let needsUnderscore: boolean = false; - - if (symbolMetadata.maxEffectiveReleaseTag === ReleaseTag.Internal) { - if (!astSymbol.parentAstSymbol) { - // If it's marked as @internal and has no parent, then it needs an underscore. - // We use maxEffectiveReleaseTag because a merged declaration would NOT need an underscore in a case like this: - // - // /** @public */ - // export enum X { } - // - // /** @internal */ - // export namespace X { } - // - // (The above normally reports an error "ae-different-release-tags", but that may be suppressed.) +function _checkForInternalUnderscore( + collector: Collector, + collectorEntity: CollectorEntity, + astSymbol: AstSymbol, + symbolMetadata: SymbolMetadata +): void { + let needsUnderscore: boolean = false; + + if (symbolMetadata.maxEffectiveReleaseTag === ReleaseTag.Internal) { + if (!astSymbol.parentAstSymbol) { + // If it's marked as @internal and has no parent, then it needs an underscore. + // We use maxEffectiveReleaseTag because a merged declaration would NOT need an underscore in a case like this: + // + // /** @public */ + // export enum X { } + // + // /** @internal */ + // export namespace X { } + // + // (The above normally reports an error "ae-different-release-tags", but that may be suppressed.) + needsUnderscore = true; + } else { + // If it's marked as @internal and the parent isn't obviously already @internal, then it needs an underscore. + // + // For example, we WOULD need an underscore for a merged declaration like this: + // + // /** @internal */ + // export namespace X { + // export interface _Y { } + // } + // + // /** @public */ + // export class X { + // /** @internal */ + // public static _Y(): void { } // <==== different from parent + // } + const parentSymbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol); + if (parentSymbolMetadata.maxEffectiveReleaseTag > ReleaseTag.Internal) { needsUnderscore = true; - } else { - // If it's marked as @internal and the parent isn't obviously already @internal, then it needs an underscore. - // - // For example, we WOULD need an underscore for a merged declaration like this: - // - // /** @internal */ - // export namespace X { - // export interface _Y { } - // } - // - // /** @public */ - // export class X { - // /** @internal */ - // public static _Y(): void { } // <==== different from parent - // } - const parentSymbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol); - if (parentSymbolMetadata.maxEffectiveReleaseTag > ReleaseTag.Internal) { - needsUnderscore = true; - } } } + } - if (needsUnderscore) { - for (const exportName of collectorEntity.exportNames) { - if (exportName[0] !== '_') { - collector.messageRouter.addAnalyzerIssue( - ExtractorMessageId.InternalMissingUnderscore, - `The name "${exportName}" should be prefixed with an underscore` + - ` because the declaration is marked as @internal`, - astSymbol, - { exportName } - ); - } + if (needsUnderscore) { + for (const exportName of collectorEntity.exportNames) { + if (exportName[0] !== '_') { + collector.messageRouter.addAnalyzerIssue( + ExtractorMessageId.InternalMissingUnderscore, + `The name "${exportName}" should be prefixed with an underscore` + + ` because the declaration is marked as @internal`, + astSymbol, + { exportName } + ); } } } +} - private static _checkForInconsistentReleaseTags( - collector: Collector, - astSymbol: AstSymbol, - symbolMetadata: SymbolMetadata - ): void { - if (astSymbol.isExternal) { - // For now, don't report errors for external code. If the developer cares about it, they should run - // API Extractor separately on the external project - return; - } - - // Normally we will expect all release tags to be the same. Arbitrarily we choose the maxEffectiveReleaseTag - // as the thing they should all match. - const expectedEffectiveReleaseTag: ReleaseTag = symbolMetadata.maxEffectiveReleaseTag; +function _checkForInconsistentReleaseTags( + collector: Collector, + astSymbol: AstSymbol, + symbolMetadata: SymbolMetadata +): void { + if (astSymbol.isExternal) { + // For now, don't report errors for external code. If the developer cares about it, they should run + // API Extractor separately on the external project + return; + } - // This is set to true if we find a declaration whose release tag is different from expectedEffectiveReleaseTag - let mixedReleaseTags: boolean = false; + // Normally we will expect all release tags to be the same. Arbitrarily we choose the maxEffectiveReleaseTag + // as the thing they should all match. + const expectedEffectiveReleaseTag: ReleaseTag = symbolMetadata.maxEffectiveReleaseTag; - // This is set to false if we find a declaration that is not a function/method overload - let onlyFunctionOverloads: boolean = true; + // This is set to true if we find a declaration whose release tag is different from expectedEffectiveReleaseTag + let mixedReleaseTags: boolean = false; - // This is set to true if we find a declaration that is @internal - let anyInternalReleaseTags: boolean = false; + // This is set to false if we find a declaration that is not a function/method overload + let onlyFunctionOverloads: boolean = true; - for (const astDeclaration of astSymbol.astDeclarations) { - const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); - const effectiveReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag; + // This is set to true if we find a declaration that is @internal + let anyInternalReleaseTags: boolean = false; - switch (astDeclaration.declaration.kind) { - case ts.SyntaxKind.FunctionDeclaration: - case ts.SyntaxKind.MethodDeclaration: - break; - default: - onlyFunctionOverloads = false; - } + for (const astDeclaration of astSymbol.astDeclarations) { + const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); + const effectiveReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag; + + switch (astDeclaration.declaration.kind) { + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.MethodDeclaration: + break; + default: + onlyFunctionOverloads = false; + } - if (effectiveReleaseTag !== expectedEffectiveReleaseTag) { - mixedReleaseTags = true; - } + if (effectiveReleaseTag !== expectedEffectiveReleaseTag) { + mixedReleaseTags = true; + } - if (effectiveReleaseTag === ReleaseTag.Internal) { - anyInternalReleaseTags = true; - } + if (effectiveReleaseTag === ReleaseTag.Internal) { + anyInternalReleaseTags = true; } + } - if (mixedReleaseTags) { - if (!onlyFunctionOverloads) { - collector.messageRouter.addAnalyzerIssue( - ExtractorMessageId.DifferentReleaseTags, - 'This symbol has another declaration with a different release tag', - astSymbol - ); - } + if (mixedReleaseTags) { + if (!onlyFunctionOverloads) { + collector.messageRouter.addAnalyzerIssue( + ExtractorMessageId.DifferentReleaseTags, + 'This symbol has another declaration with a different release tag', + astSymbol + ); + } - if (anyInternalReleaseTags) { - collector.messageRouter.addAnalyzerIssue( - ExtractorMessageId.InternalMixedReleaseTag, - `Mixed release tags are not allowed for "${astSymbol.localName}" because one of its declarations` + - ` is marked as @internal`, - astSymbol - ); - } + if (anyInternalReleaseTags) { + collector.messageRouter.addAnalyzerIssue( + ExtractorMessageId.InternalMixedReleaseTag, + `Mixed release tags are not allowed for "${astSymbol.localName}" because one of its declarations` + + ` is marked as @internal`, + astSymbol + ); } } +} - private static _checkReferences( - collector: Collector, - astDeclaration: AstDeclaration, - alreadyWarnedEntities: Set - ): void { - const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); - const declarationReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag; - - for (const referencedEntity of astDeclaration.referencedAstEntities) { - let collectorEntity: CollectorEntity | undefined; - let referencedReleaseTag: ReleaseTag; - let localName: string; - - if (referencedEntity instanceof AstSymbol) { - // If this is e.g. a member of a namespace, then we need to be checking the top-level scope to see - // whether it's exported. - // - // TODO: Technically we should also check each of the nested scopes along the way. - const rootSymbol: AstSymbol = referencedEntity.rootAstSymbol; - - if (rootSymbol.isExternal) { - continue; - } +function _checkReferences( + collector: Collector, + astDeclaration: AstDeclaration, + alreadyWarnedEntities: Set +): void { + const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); + const declarationReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag; + + for (const referencedEntity of astDeclaration.referencedAstEntities) { + let collectorEntity: CollectorEntity | undefined; + let referencedReleaseTag: ReleaseTag; + let localName: string; + + if (referencedEntity instanceof AstSymbol) { + // If this is e.g. a member of a namespace, then we need to be checking the top-level scope to see + // whether it's exported. + // + // TODO: Technically we should also check each of the nested scopes along the way. + const rootSymbol: AstSymbol = referencedEntity.rootAstSymbol; + + if (rootSymbol.isExternal) { + continue; + } - collectorEntity = collector.tryGetCollectorEntity(rootSymbol); - localName = collectorEntity?.nameForEmit || rootSymbol.localName; + collectorEntity = collector.tryGetCollectorEntity(rootSymbol); + localName = collectorEntity?.nameForEmit || rootSymbol.localName; - const referencedMetadata: SymbolMetadata = collector.fetchSymbolMetadata(referencedEntity); - referencedReleaseTag = referencedMetadata.maxEffectiveReleaseTag; - } else if (referencedEntity instanceof AstNamespaceImport) { - collectorEntity = collector.tryGetCollectorEntity(referencedEntity); + const referencedMetadata: SymbolMetadata = collector.fetchSymbolMetadata(referencedEntity); + referencedReleaseTag = referencedMetadata.maxEffectiveReleaseTag; + } else if (referencedEntity instanceof AstNamespaceImport) { + collectorEntity = collector.tryGetCollectorEntity(referencedEntity); - // TODO: Currently the "import * as ___ from ___" syntax does not yet support doc comments - referencedReleaseTag = ReleaseTag.Public; + // TODO: Currently the "import * as ___ from ___" syntax does not yet support doc comments + referencedReleaseTag = ReleaseTag.Public; - localName = collectorEntity?.nameForEmit || referencedEntity.localName; - } else { - continue; - } + localName = collectorEntity?.nameForEmit || referencedEntity.localName; + } else { + continue; + } - if (collectorEntity && collectorEntity.consumable) { - if (ReleaseTag.compare(declarationReleaseTag, referencedReleaseTag) > 0) { + if (collectorEntity && collectorEntity.consumable) { + if (ReleaseTag.compare(declarationReleaseTag, referencedReleaseTag) > 0) { + collector.messageRouter.addAnalyzerIssue( + ExtractorMessageId.IncompatibleReleaseTags, + `The symbol "${astDeclaration.astSymbol.localName}"` + + ` is marked as ${ReleaseTag.getTagName(declarationReleaseTag)},` + + ` but its signature references "${localName}"` + + ` which is marked as ${ReleaseTag.getTagName(referencedReleaseTag)}`, + astDeclaration + ); + } + } else { + const entryPointFilename: string = path.basename( + collector.workingPackage.entryPointSourceFile.fileName + ); + + if (!alreadyWarnedEntities.has(referencedEntity)) { + alreadyWarnedEntities.add(referencedEntity); + + if (referencedEntity instanceof AstSymbol && _isEcmaScriptSymbol(referencedEntity)) { + // The main usage scenario for ECMAScript symbols is to attach private data to a JavaScript object, + // so as a special case, we do NOT report them as forgotten exports. + } else { collector.messageRouter.addAnalyzerIssue( - ExtractorMessageId.IncompatibleReleaseTags, - `The symbol "${astDeclaration.astSymbol.localName}"` + - ` is marked as ${ReleaseTag.getTagName(declarationReleaseTag)},` + - ` but its signature references "${localName}"` + - ` which is marked as ${ReleaseTag.getTagName(referencedReleaseTag)}`, + ExtractorMessageId.ForgottenExport, + `The symbol "${localName}" needs to be exported by the entry point ${entryPointFilename}`, astDeclaration ); } - } else { - const entryPointFilename: string = path.basename( - collector.workingPackage.entryPointSourceFile.fileName - ); - - if (!alreadyWarnedEntities.has(referencedEntity)) { - alreadyWarnedEntities.add(referencedEntity); - - if ( - referencedEntity instanceof AstSymbol && - ValidationEnhancer._isEcmaScriptSymbol(referencedEntity) - ) { - // The main usage scenario for ECMAScript symbols is to attach private data to a JavaScript object, - // so as a special case, we do NOT report them as forgotten exports. - } else { - collector.messageRouter.addAnalyzerIssue( - ExtractorMessageId.ForgottenExport, - `The symbol "${localName}" needs to be exported by the entry point ${entryPointFilename}`, - astDeclaration - ); - } - } } } } +} - // Detect an AstSymbol that refers to an ECMAScript symbol declaration such as: - // - // const mySymbol: unique symbol = Symbol('mySymbol'); - private static _isEcmaScriptSymbol(astSymbol: AstSymbol): boolean { - if (astSymbol.astDeclarations.length !== 1) { - return false; - } +// Detect an AstSymbol that refers to an ECMAScript symbol declaration such as: +// +// const mySymbol: unique symbol = Symbol('mySymbol'); +function _isEcmaScriptSymbol(astSymbol: AstSymbol): boolean { + if (astSymbol.astDeclarations.length !== 1) { + return false; + } - // We are matching a form like this: - // - // - VariableDeclaration: - // - Identifier: pre=[mySymbol] - // - ColonToken: pre=[:] sep=[ ] - // - TypeOperator: - // - UniqueKeyword: pre=[unique] sep=[ ] - // - SymbolKeyword: pre=[symbol] - const astDeclaration: AstDeclaration = astSymbol.astDeclarations[0]; - if (ts.isVariableDeclaration(astDeclaration.declaration)) { - const variableTypeNode: ts.TypeNode | undefined = astDeclaration.declaration.type; - if (variableTypeNode) { - for (const token of variableTypeNode.getChildren()) { - if (token.kind === ts.SyntaxKind.SymbolKeyword) { - return true; - } + // We are matching a form like this: + // + // - VariableDeclaration: + // - Identifier: pre=[mySymbol] + // - ColonToken: pre=[:] sep=[ ] + // - TypeOperator: + // - UniqueKeyword: pre=[unique] sep=[ ] + // - SymbolKeyword: pre=[symbol] + const astDeclaration: AstDeclaration = astSymbol.astDeclarations[0]; + if (ts.isVariableDeclaration(astDeclaration.declaration)) { + const variableTypeNode: ts.TypeNode | undefined = astDeclaration.declaration.type; + if (variableTypeNode) { + for (const token of variableTypeNode.getChildren()) { + if (token.kind === ts.SyntaxKind.SymbolKeyword) { + return true; } } } - - return false; } + + return false; } diff --git a/apps/api-extractor/src/generators/ApiReportGenerator.ts b/apps/api-extractor/src/generators/ApiReportGenerator.ts index c4db9b49bd8..41ab7b71d86 100644 --- a/apps/api-extractor/src/generators/ApiReportGenerator.ts +++ b/apps/api-extractor/src/generators/ApiReportGenerator.ts @@ -32,9 +32,9 @@ interface IContext { alreadyProcessedSignatures: Set; } -export class ApiReportGenerator { - private static _trimSpacesRegExp: RegExp = / +$/gm; +const _trimSpacesRegExp: RegExp = / +$/gm; +export class ApiReportGenerator { /** * Compares the contents of two API files that were created using ApiFileGenerator, * and returns true if they are equivalent. Note that these files are not normally edited @@ -111,7 +111,7 @@ export class ApiReportGenerator { const symbolMetadata: SymbolMetadata | undefined = collector.tryFetchMetadataForAstEntity(astEntity); const maxEffectiveReleaseTag: ReleaseTag = symbolMetadata?.maxEffectiveReleaseTag ?? ReleaseTag.None; - if (!this._shouldIncludeReleaseTag(maxEffectiveReleaseTag, reportVariant)) { + if (!_shouldIncludeReleaseTag(maxEffectiveReleaseTag, reportVariant)) { continue; } @@ -155,17 +155,17 @@ export class ApiReportGenerator { messagesToReport.push(message); } - if (this._shouldIncludeDeclaration(collector, astDeclaration, reportVariant)) { + if (_shouldIncludeDeclaration(collector, astDeclaration, reportVariant)) { writer.ensureSkippedLine(); - writer.write(ApiReportGenerator._getAedocSynopsis(collector, astDeclaration, messagesToReport)); + writer.write(_getAedocSynopsis(collector, astDeclaration, messagesToReport)); const span: Span = new Span(astDeclaration.declaration); const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); if (apiItemMetadata.isPreapproved) { - ApiReportGenerator._modifySpanForPreapproved(span); + _modifySpanForPreapproved(span); } else { - ApiReportGenerator._modifySpan(span, entity, astDeclaration, false, context); + _modifySpan(span, entity, astDeclaration, false, context); } span.writeModifiedText(writer); @@ -245,10 +245,7 @@ export class ApiReportGenerator { if (exportToEmit.associatedMessages.length > 0) { writer.ensureSkippedLine(); for (const message of exportToEmit.associatedMessages) { - ApiReportGenerator._writeLineAsComments( - writer, - 'Warning: ' + message.formatMessageWithoutLocation() - ); + _writeLineAsComments(writer, 'Warning: ' + message.formatMessageWithoutLocation()); } } @@ -265,10 +262,10 @@ export class ApiReportGenerator { collector.messageRouter.fetchUnassociatedMessagesForReviewFile(); if (unassociatedMessages.length > 0) { writer.ensureSkippedLine(); - ApiReportGenerator._writeLineAsComments(writer, 'Warnings were encountered during analysis:'); - ApiReportGenerator._writeLineAsComments(writer, ''); + _writeLineAsComments(writer, 'Warnings were encountered during analysis:'); + _writeLineAsComments(writer, ''); for (const unassociatedMessage of unassociatedMessages) { - ApiReportGenerator._writeLineAsComments( + _writeLineAsComments( writer, unassociatedMessage.formatMessageWithLocation(collector.workingPackage.packageFolder) ); @@ -277,7 +274,7 @@ export class ApiReportGenerator { if (collector.workingPackage.tsdocComment === undefined) { writer.ensureSkippedLine(); - ApiReportGenerator._writeLineAsComments(writer, '(No @packageDocumentation comment for this package)'); + _writeLineAsComments(writer, '(No @packageDocumentation comment for this package)'); } // Write the closing delimiter for the Markdown code fence @@ -285,408 +282,398 @@ export class ApiReportGenerator { writer.writeLine('```'); // Remove any trailing spaces - return writer.toString().replace(ApiReportGenerator._trimSpacesRegExp, ''); + return writer.toString().replace(_trimSpacesRegExp, ''); } +} - /** - * Before writing out a declaration, _modifySpan() applies various fixups to make it nice. - */ - private static _modifySpan( - span: Span, - entity: CollectorEntity, - astDeclaration: AstDeclaration, - insideTypeLiteral: boolean, - context: IContext - ): void { - const { collector, reportVariant } = context; - - // Should we process this declaration at all? - if (!ApiReportGenerator._shouldIncludeDeclaration(collector, astDeclaration, reportVariant)) { - span.modification.skipAll(); - return; - } - - const previousSpan: Span | undefined = span.previousSibling; +/** + * Before writing out a declaration, _modifySpan() applies various fixups to make it nice. + */ +function _modifySpan( + span: Span, + entity: CollectorEntity, + astDeclaration: AstDeclaration, + insideTypeLiteral: boolean, + context: IContext +): void { + const { collector, reportVariant } = context; + + // Should we process this declaration at all? + if (!_shouldIncludeDeclaration(collector, astDeclaration, reportVariant)) { + span.modification.skipAll(); + return; + } - let recurseChildren: boolean = true; - let sortChildren: boolean = false; + const previousSpan: Span | undefined = span.previousSibling; - switch (span.kind) { - case ts.SyntaxKind.JSDocComment: - span.modification.skipAll(); - // For now, we don't transform JSDoc comment nodes at all - recurseChildren = false; - break; + let recurseChildren: boolean = true; + let sortChildren: boolean = false; - case ts.SyntaxKind.ExportKeyword: - if (DtsEmitHelpers.isExportKeywordInNamespaceExportDeclaration(span.node)) { - // This is an export declaration inside a namespace - preserve the export keyword - break; - } - // Otherwise, delete the export keyword -- we will re-add it below - span.modification.skipAll(); - break; + switch (span.kind) { + case ts.SyntaxKind.JSDocComment: + span.modification.skipAll(); + // For now, we don't transform JSDoc comment nodes at all + recurseChildren = false; + break; - case ts.SyntaxKind.DefaultKeyword: - case ts.SyntaxKind.DeclareKeyword: - // Delete any explicit "export" or "declare" keywords -- we will re-add them below - span.modification.skipAll(); + case ts.SyntaxKind.ExportKeyword: + if (DtsEmitHelpers.isExportKeywordInNamespaceExportDeclaration(span.node)) { + // This is an export declaration inside a namespace - preserve the export keyword break; + } + // Otherwise, delete the export keyword -- we will re-add it below + span.modification.skipAll(); + break; - case ts.SyntaxKind.InterfaceKeyword: - case ts.SyntaxKind.ClassKeyword: - case ts.SyntaxKind.EnumKeyword: - case ts.SyntaxKind.NamespaceKeyword: - case ts.SyntaxKind.ModuleKeyword: - case ts.SyntaxKind.TypeKeyword: - case ts.SyntaxKind.FunctionKeyword: - // Replace the stuff we possibly deleted above - let replacedModifiers: string = ''; + case ts.SyntaxKind.DefaultKeyword: + case ts.SyntaxKind.DeclareKeyword: + // Delete any explicit "export" or "declare" keywords -- we will re-add them below + span.modification.skipAll(); + break; + + case ts.SyntaxKind.InterfaceKeyword: + case ts.SyntaxKind.ClassKeyword: + case ts.SyntaxKind.EnumKeyword: + case ts.SyntaxKind.NamespaceKeyword: + case ts.SyntaxKind.ModuleKeyword: + case ts.SyntaxKind.TypeKeyword: + case ts.SyntaxKind.FunctionKeyword: + // Replace the stuff we possibly deleted above + let replacedModifiers: string = ''; + + if (entity.shouldInlineExport) { + replacedModifiers = 'export ' + replacedModifiers; + } - if (entity.shouldInlineExport) { - replacedModifiers = 'export ' + replacedModifiers; + if (previousSpan && previousSpan.kind === ts.SyntaxKind.SyntaxList) { + // If there is a previous span of type SyntaxList, then apply it before any other modifiers + // (e.g. "abstract") that appear there. + previousSpan.modification.prefix = replacedModifiers + previousSpan.modification.prefix; + } else { + // Otherwise just stick it in front of this span + span.modification.prefix = replacedModifiers + span.modification.prefix; + } + break; + + case ts.SyntaxKind.SyntaxList: + if (span.parent) { + if (AstDeclaration.isSupportedSyntaxKind(span.parent.kind)) { + // If the immediate parent is an API declaration, and the immediate children are API declarations, + // then sort the children alphabetically + sortChildren = true; + } else if (span.parent.kind === ts.SyntaxKind.ModuleBlock) { + // Namespaces are special because their chain goes ModuleDeclaration -> ModuleBlock -> SyntaxList + sortChildren = true; } - - if (previousSpan && previousSpan.kind === ts.SyntaxKind.SyntaxList) { - // If there is a previous span of type SyntaxList, then apply it before any other modifiers - // (e.g. "abstract") that appear there. - previousSpan.modification.prefix = replacedModifiers + previousSpan.modification.prefix; - } else { - // Otherwise just stick it in front of this span - span.modification.prefix = replacedModifiers + span.modification.prefix; + } + break; + + case ts.SyntaxKind.VariableDeclaration: + if (!span.parent) { + // The VariableDeclaration node is part of a VariableDeclarationList, however + // the Entry.followedSymbol points to the VariableDeclaration part because + // multiple definitions might share the same VariableDeclarationList. + // + // Since we are emitting a separate declaration for each one, we need to look upwards + // in the ts.Node tree and write a copy of the enclosing VariableDeclarationList + // content (e.g. "var" from "var x=1, y=2"). + const list: ts.VariableDeclarationList | undefined = TypeScriptHelpers.matchAncestor(span.node, [ + ts.SyntaxKind.VariableDeclarationList, + ts.SyntaxKind.VariableDeclaration + ]); + if (!list) { + // This should not happen unless the compiler API changes somehow + throw new InternalError('Unsupported variable declaration'); } - break; + const listPrefix: string = list + .getSourceFile() + .text.substring(list.getStart(), list.declarations[0].getStart()); + span.modification.prefix = listPrefix + span.modification.prefix; + span.modification.suffix = ';'; - case ts.SyntaxKind.SyntaxList: - if (span.parent) { - if (AstDeclaration.isSupportedSyntaxKind(span.parent.kind)) { - // If the immediate parent is an API declaration, and the immediate children are API declarations, - // then sort the children alphabetically - sortChildren = true; - } else if (span.parent.kind === ts.SyntaxKind.ModuleBlock) { - // Namespaces are special because their chain goes ModuleDeclaration -> ModuleBlock -> SyntaxList - sortChildren = true; - } + if (entity.shouldInlineExport) { + span.modification.prefix = 'export ' + span.modification.prefix; } - break; - - case ts.SyntaxKind.VariableDeclaration: - if (!span.parent) { - // The VariableDeclaration node is part of a VariableDeclarationList, however - // the Entry.followedSymbol points to the VariableDeclaration part because - // multiple definitions might share the same VariableDeclarationList. - // - // Since we are emitting a separate declaration for each one, we need to look upwards - // in the ts.Node tree and write a copy of the enclosing VariableDeclarationList - // content (e.g. "var" from "var x=1, y=2"). - const list: ts.VariableDeclarationList | undefined = TypeScriptHelpers.matchAncestor(span.node, [ - ts.SyntaxKind.VariableDeclarationList, - ts.SyntaxKind.VariableDeclaration - ]); - if (!list) { - // This should not happen unless the compiler API changes somehow - throw new InternalError('Unsupported variable declaration'); - } - const listPrefix: string = list - .getSourceFile() - .text.substring(list.getStart(), list.declarations[0].getStart()); - span.modification.prefix = listPrefix + span.modification.prefix; - span.modification.suffix = ';'; - - if (entity.shouldInlineExport) { - span.modification.prefix = 'export ' + span.modification.prefix; + } + break; + + case ts.SyntaxKind.Parameter: + { + // (signature) -> SyntaxList -> Parameter + const signatureParent: Span | undefined = span.parent; + if (signatureParent) { + if (!context.alreadyProcessedSignatures.has(signatureParent)) { + context.alreadyProcessedSignatures.add(signatureParent); + DtsEmitHelpers.normalizeParameterNames(signatureParent); } } - break; + } + break; - case ts.SyntaxKind.Parameter: - { - // (signature) -> SyntaxList -> Parameter - const signatureParent: Span | undefined = span.parent; - if (signatureParent) { - if (!context.alreadyProcessedSignatures.has(signatureParent)) { - context.alreadyProcessedSignatures.add(signatureParent); - DtsEmitHelpers.normalizeParameterNames(signatureParent); - } - } + case ts.SyntaxKind.Identifier: + const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode( + span.node as ts.Identifier + ); + + if (referencedEntity) { + if (!referencedEntity.nameForEmit) { + // This should never happen + throw new InternalError('referencedEntry.nameForEmit is undefined'); } - break; - case ts.SyntaxKind.Identifier: - const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode( - span.node as ts.Identifier - ); + span.modification.prefix = referencedEntity.nameForEmit; + // For debugging: + // span.modification.prefix += '/*R=FIX*/'; + } else { + // For debugging: + // span.modification.prefix += '/*R=KEEP*/'; + } - if (referencedEntity) { - if (!referencedEntity.nameForEmit) { - // This should never happen - throw new InternalError('referencedEntry.nameForEmit is undefined'); - } + break; - span.modification.prefix = referencedEntity.nameForEmit; - // For debugging: - // span.modification.prefix += '/*R=FIX*/'; - } else { - // For debugging: - // span.modification.prefix += '/*R=KEEP*/'; + case ts.SyntaxKind.TypeLiteral: + insideTypeLiteral = true; + break; + + case ts.SyntaxKind.ImportType: + DtsEmitHelpers.modifyImportTypeSpan( + collector, + span, + astDeclaration, + (childSpan, childAstDeclaration) => { + _modifySpan(childSpan, entity, childAstDeclaration, insideTypeLiteral, context); } + ); + break; + } - break; + if (recurseChildren) { + for (const child of span.children) { + let childAstDeclaration: AstDeclaration = astDeclaration; - case ts.SyntaxKind.TypeLiteral: - insideTypeLiteral = true; - break; + if (AstDeclaration.isSupportedSyntaxKind(child.kind)) { + childAstDeclaration = collector.astSymbolTable.getChildAstDeclarationByNode( + child.node, + astDeclaration + ); - case ts.SyntaxKind.ImportType: - DtsEmitHelpers.modifyImportTypeSpan( - collector, - span, - astDeclaration, - (childSpan, childAstDeclaration) => { - ApiReportGenerator._modifySpan( - childSpan, - entity, - childAstDeclaration, - insideTypeLiteral, - context + if (_shouldIncludeDeclaration(collector, childAstDeclaration, reportVariant)) { + if (sortChildren) { + span.modification.sortChildren = true; + child.modification.sortKey = Collector.getSortKeyIgnoringUnderscore( + childAstDeclaration.astSymbol.localName ); } - ); - break; - } - if (recurseChildren) { - for (const child of span.children) { - let childAstDeclaration: AstDeclaration = astDeclaration; - - if (AstDeclaration.isSupportedSyntaxKind(child.kind)) { - childAstDeclaration = collector.astSymbolTable.getChildAstDeclarationByNode( - child.node, - astDeclaration - ); - - if (ApiReportGenerator._shouldIncludeDeclaration(collector, childAstDeclaration, reportVariant)) { - if (sortChildren) { - span.modification.sortChildren = true; - child.modification.sortKey = Collector.getSortKeyIgnoringUnderscore( - childAstDeclaration.astSymbol.localName - ); - } + if (!insideTypeLiteral) { + const messagesToReport: ExtractorMessage[] = + collector.messageRouter.fetchAssociatedMessagesForReviewFile(childAstDeclaration); - if (!insideTypeLiteral) { - const messagesToReport: ExtractorMessage[] = - collector.messageRouter.fetchAssociatedMessagesForReviewFile(childAstDeclaration); + // NOTE: This generates ae-undocumented messages as a side effect + const aedocSynopsis: string = _getAedocSynopsis(collector, childAstDeclaration, messagesToReport); - // NOTE: This generates ae-undocumented messages as a side effect - const aedocSynopsis: string = ApiReportGenerator._getAedocSynopsis( - collector, - childAstDeclaration, - messagesToReport - ); - - child.modification.prefix = aedocSynopsis + child.modification.prefix; - } + child.modification.prefix = aedocSynopsis + child.modification.prefix; } } - - ApiReportGenerator._modifySpan(child, entity, childAstDeclaration, insideTypeLiteral, context); } + + _modifySpan(child, entity, childAstDeclaration, insideTypeLiteral, context); } } +} - private static _shouldIncludeDeclaration( - collector: Collector, - astDeclaration: AstDeclaration, - reportVariant: ApiReportVariant - ): boolean { - // Private declarations are not included in the API report - // eslint-disable-next-line no-bitwise - if ((astDeclaration.modifierFlags & ts.ModifierFlags.Private) !== 0) { - return false; - } +function _shouldIncludeDeclaration( + collector: Collector, + astDeclaration: AstDeclaration, + reportVariant: ApiReportVariant +): boolean { + // Private declarations are not included in the API report + // eslint-disable-next-line no-bitwise + if ((astDeclaration.modifierFlags & ts.ModifierFlags.Private) !== 0) { + return false; + } - const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); + const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); + + return _shouldIncludeReleaseTag(apiItemMetadata.effectiveReleaseTag, reportVariant); +} - return this._shouldIncludeReleaseTag(apiItemMetadata.effectiveReleaseTag, reportVariant); +function _shouldIncludeReleaseTag(releaseTag: ReleaseTag, reportVariant: ApiReportVariant): boolean { + switch (reportVariant) { + case 'complete': + return true; + case 'alpha': + return ( + releaseTag === ReleaseTag.Alpha || + releaseTag === ReleaseTag.Beta || + releaseTag === ReleaseTag.Public || + // NOTE: No specified release tag is implicitly treated as `@public`. + releaseTag === ReleaseTag.None + ); + case 'beta': + return ( + releaseTag === ReleaseTag.Beta || + releaseTag === ReleaseTag.Public || + // NOTE: No specified release tag is implicitly treated as `@public`. + releaseTag === ReleaseTag.None + ); + case 'public': + return ( + releaseTag === ReleaseTag.Public || + // NOTE: No specified release tag is implicitly treated as `@public`. + releaseTag === ReleaseTag.None + ); + default: + throw new Error(`Unrecognized release level: ${reportVariant}`); } +} - private static _shouldIncludeReleaseTag(releaseTag: ReleaseTag, reportVariant: ApiReportVariant): boolean { - switch (reportVariant) { - case 'complete': - return true; - case 'alpha': - return ( - releaseTag === ReleaseTag.Alpha || - releaseTag === ReleaseTag.Beta || - releaseTag === ReleaseTag.Public || - // NOTE: No specified release tag is implicitly treated as `@public`. - releaseTag === ReleaseTag.None - ); - case 'beta': - return ( - releaseTag === ReleaseTag.Beta || - releaseTag === ReleaseTag.Public || - // NOTE: No specified release tag is implicitly treated as `@public`. - releaseTag === ReleaseTag.None - ); - case 'public': - return ( - releaseTag === ReleaseTag.Public || - // NOTE: No specified release tag is implicitly treated as `@public`. - releaseTag === ReleaseTag.None - ); - default: - throw new Error(`Unrecognized release level: ${reportVariant}`); +/** + * For declarations marked as `@preapproved`, this is used instead of _modifySpan(). + */ +function _modifySpanForPreapproved(span: Span): void { + // Match something like this: + // + // ClassDeclaration: + // SyntaxList: + // ExportKeyword: pre=[export] sep=[ ] + // DeclareKeyword: pre=[declare] sep=[ ] + // ClassKeyword: pre=[class] sep=[ ] + // Identifier: pre=[_PreapprovedClass] sep=[ ] + // FirstPunctuation: pre=[{] sep=[\n\n ] + // SyntaxList: + // ... + // CloseBraceToken: pre=[}] + // + // or this: + // ModuleDeclaration: + // SyntaxList: + // ExportKeyword: pre=[export] sep=[ ] + // DeclareKeyword: pre=[declare] sep=[ ] + // NamespaceKeyword: pre=[namespace] sep=[ ] + // Identifier: pre=[_PreapprovedNamespace] sep=[ ] + // ModuleBlock: + // FirstPunctuation: pre=[{] sep=[\n\n ] + // SyntaxList: + // ... + // CloseBraceToken: pre=[}] + // + // And reduce it to something like this: + // + // // @internal (undocumented) + // class _PreapprovedClass { /* (preapproved) */ } + // + + let skipRest: boolean = false; + for (const child of span.children) { + if (skipRest || child.kind === ts.SyntaxKind.SyntaxList || child.kind === ts.SyntaxKind.JSDocComment) { + child.modification.skipAll(); + } + if (child.kind === ts.SyntaxKind.Identifier) { + skipRest = true; + child.modification.omitSeparatorAfter = true; + child.modification.suffix = ' { /* (preapproved) */ }'; } } +} - /** - * For declarations marked as `@preapproved`, this is used instead of _modifySpan(). - */ - private static _modifySpanForPreapproved(span: Span): void { - // Match something like this: - // - // ClassDeclaration: - // SyntaxList: - // ExportKeyword: pre=[export] sep=[ ] - // DeclareKeyword: pre=[declare] sep=[ ] - // ClassKeyword: pre=[class] sep=[ ] - // Identifier: pre=[_PreapprovedClass] sep=[ ] - // FirstPunctuation: pre=[{] sep=[\n\n ] - // SyntaxList: - // ... - // CloseBraceToken: pre=[}] - // - // or this: - // ModuleDeclaration: - // SyntaxList: - // ExportKeyword: pre=[export] sep=[ ] - // DeclareKeyword: pre=[declare] sep=[ ] - // NamespaceKeyword: pre=[namespace] sep=[ ] - // Identifier: pre=[_PreapprovedNamespace] sep=[ ] - // ModuleBlock: - // FirstPunctuation: pre=[{] sep=[\n\n ] - // SyntaxList: - // ... - // CloseBraceToken: pre=[}] - // - // And reduce it to something like this: - // - // // @internal (undocumented) - // class _PreapprovedClass { /* (preapproved) */ } - // - - let skipRest: boolean = false; - for (const child of span.children) { - if (skipRest || child.kind === ts.SyntaxKind.SyntaxList || child.kind === ts.SyntaxKind.JSDocComment) { - child.modification.skipAll(); - } - if (child.kind === ts.SyntaxKind.Identifier) { - skipRest = true; - child.modification.omitSeparatorAfter = true; - child.modification.suffix = ' { /* (preapproved) */ }'; - } - } +/** + * Writes a synopsis of the AEDoc comments, which indicates the release tag, + * whether the item has been documented, and any warnings that were detected + * by the analysis. + */ +function _getAedocSynopsis( + collector: Collector, + astDeclaration: AstDeclaration, + messagesToReport: ExtractorMessage[] +): string { + const writer: IndentedWriter = new IndentedWriter(); + + for (const message of messagesToReport) { + _writeLineAsComments(writer, 'Warning: ' + message.formatMessageWithoutLocation()); } - /** - * Writes a synopsis of the AEDoc comments, which indicates the release tag, - * whether the item has been documented, and any warnings that were detected - * by the analysis. - */ - private static _getAedocSynopsis( - collector: Collector, - astDeclaration: AstDeclaration, - messagesToReport: ExtractorMessage[] - ): string { - const writer: IndentedWriter = new IndentedWriter(); + if (!collector.isAncillaryDeclaration(astDeclaration)) { + const footerParts: string[] = []; + const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); - for (const message of messagesToReport) { - ApiReportGenerator._writeLineAsComments(writer, 'Warning: ' + message.formatMessageWithoutLocation()); + // 1. Release tag (if present) + if (!apiItemMetadata.releaseTagSameAsParent) { + if (apiItemMetadata.effectiveReleaseTag !== ReleaseTag.None) { + footerParts.push(ReleaseTag.getTagName(apiItemMetadata.effectiveReleaseTag)); + } } - if (!collector.isAncillaryDeclaration(astDeclaration)) { - const footerParts: string[] = []; - const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); + // 2. Enumerate configured tags, reporting standard system tags first and then other configured tags. + // Note that the ordering we handle the standard tags is important for backwards compatibility. + // Also note that we had special mechanisms for checking whether or not an item is documented with these tags, + // so they are checked specially. + const { + '@sealed': reportSealedTag, + '@virtual': reportVirtualTag, + '@override': reportOverrideTag, + '@eventProperty': reportEventPropertyTag, + '@deprecated': reportDeprecatedTag, + ...otherTagsToReport + } = collector.extractorConfig.tagsToReport; + + // 2.a Check for standard tags and report those that are both configured and present in the metadata. + if (reportSealedTag && apiItemMetadata.isSealed) { + footerParts.push('@sealed'); + } + if (reportVirtualTag && apiItemMetadata.isVirtual) { + footerParts.push('@virtual'); + } + if (reportOverrideTag && apiItemMetadata.isOverride) { + footerParts.push('@override'); + } + if (reportEventPropertyTag && apiItemMetadata.isEventProperty) { + footerParts.push('@eventProperty'); + } + if (reportDeprecatedTag && apiItemMetadata.tsdocComment?.deprecatedBlock) { + footerParts.push('@deprecated'); + } - // 1. Release tag (if present) - if (!apiItemMetadata.releaseTagSameAsParent) { - if (apiItemMetadata.effectiveReleaseTag !== ReleaseTag.None) { - footerParts.push(ReleaseTag.getTagName(apiItemMetadata.effectiveReleaseTag)); + // 2.b Check for other configured tags and report those that are present in the tsdoc metadata. + for (const [tag, reportTag] of Object.entries(otherTagsToReport)) { + if (reportTag) { + // If the tag was not handled specially, check if it is present in the metadata. + if (apiItemMetadata.tsdocComment?.customBlocks.some((block) => block.blockTag.tagName === tag)) { + footerParts.push(tag); + } else if (apiItemMetadata.tsdocComment?.modifierTagSet.hasTagName(tag)) { + footerParts.push(tag); } } + } - // 2. Enumerate configured tags, reporting standard system tags first and then other configured tags. - // Note that the ordering we handle the standard tags is important for backwards compatibility. - // Also note that we had special mechanisms for checking whether or not an item is documented with these tags, - // so they are checked specially. - const { - '@sealed': reportSealedTag, - '@virtual': reportVirtualTag, - '@override': reportOverrideTag, - '@eventProperty': reportEventPropertyTag, - '@deprecated': reportDeprecatedTag, - ...otherTagsToReport - } = collector.extractorConfig.tagsToReport; - - // 2.a Check for standard tags and report those that are both configured and present in the metadata. - if (reportSealedTag && apiItemMetadata.isSealed) { - footerParts.push('@sealed'); - } - if (reportVirtualTag && apiItemMetadata.isVirtual) { - footerParts.push('@virtual'); - } - if (reportOverrideTag && apiItemMetadata.isOverride) { - footerParts.push('@override'); - } - if (reportEventPropertyTag && apiItemMetadata.isEventProperty) { - footerParts.push('@eventProperty'); - } - if (reportDeprecatedTag && apiItemMetadata.tsdocComment?.deprecatedBlock) { - footerParts.push('@deprecated'); - } - - // 2.b Check for other configured tags and report those that are present in the tsdoc metadata. - for (const [tag, reportTag] of Object.entries(otherTagsToReport)) { - if (reportTag) { - // If the tag was not handled specially, check if it is present in the metadata. - if (apiItemMetadata.tsdocComment?.customBlocks.some((block) => block.blockTag.tagName === tag)) { - footerParts.push(tag); - } else if (apiItemMetadata.tsdocComment?.modifierTagSet.hasTagName(tag)) { - footerParts.push(tag); - } - } - } + // 3. If the item is undocumented, append notice at the end of the list + if (apiItemMetadata.undocumented) { + footerParts.push('(undocumented)'); - // 3. If the item is undocumented, append notice at the end of the list - if (apiItemMetadata.undocumented) { - footerParts.push('(undocumented)'); + collector.messageRouter.addAnalyzerIssue( + ExtractorMessageId.Undocumented, + `Missing documentation for "${astDeclaration.astSymbol.localName}".`, + astDeclaration + ); + } - collector.messageRouter.addAnalyzerIssue( - ExtractorMessageId.Undocumented, - `Missing documentation for "${astDeclaration.astSymbol.localName}".`, - astDeclaration - ); + if (footerParts.length > 0) { + if (messagesToReport.length > 0) { + _writeLineAsComments(writer, ''); // skip a line after the warnings } - if (footerParts.length > 0) { - if (messagesToReport.length > 0) { - ApiReportGenerator._writeLineAsComments(writer, ''); // skip a line after the warnings - } - - ApiReportGenerator._writeLineAsComments(writer, footerParts.join(' ')); - } + _writeLineAsComments(writer, footerParts.join(' ')); } - - return writer.toString(); } - private static _writeLineAsComments(writer: IndentedWriter, line: string): void { - const lines: string[] = Text.convertToLf(line).split('\n'); - for (const realLine of lines) { - writer.write('// '); - writer.write(realLine); - writer.writeLine(); - } + return writer.toString(); +} + +function _writeLineAsComments(writer: IndentedWriter, line: string): void { + const lines: string[] = Text.convertToLf(line).split('\n'); + for (const realLine of lines) { + writer.write('// '); + writer.write(realLine); + writer.writeLine(); } } diff --git a/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts b/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts index 6f136fb9829..bb81031afe4 100644 --- a/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts +++ b/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts @@ -34,7 +34,7 @@ export class DeclarationReferenceGenerator { public getDeclarationReferenceForIdentifier(node: ts.Identifier): DeclarationReference | undefined { const symbol: ts.Symbol | undefined = this._collector.typeChecker.getSymbolAtLocation(node); if (symbol !== undefined) { - const isExpression: boolean = DeclarationReferenceGenerator._isInExpressionContext(node); + const isExpression: boolean = _isInExpressionContext(node); return ( this.getDeclarationReferenceForSymbol( symbol, @@ -59,38 +59,6 @@ export class DeclarationReferenceGenerator { return this._symbolToDeclarationReference(symbol, meaning, /*includeModuleSymbols*/ false); } - private static _isInExpressionContext(node: ts.Node): boolean { - switch (node.parent.kind) { - case ts.SyntaxKind.TypeQuery: - case ts.SyntaxKind.ComputedPropertyName: - return true; - case ts.SyntaxKind.QualifiedName: - return DeclarationReferenceGenerator._isInExpressionContext(node.parent); - default: - return false; - } - } - - private static _isExternalModuleSymbol(symbol: ts.Symbol): boolean { - return ( - !!(symbol.flags & ts.SymbolFlags.ValueModule) && - symbol.valueDeclaration !== undefined && - ts.isSourceFile(symbol.valueDeclaration) - ); - } - - private static _isSameSymbol(left: ts.Symbol | undefined, right: ts.Symbol): boolean { - return ( - left === right || - !!( - left && - left.valueDeclaration && - right.valueDeclaration && - left.valueDeclaration === right.valueDeclaration - ) - ); - } - private _getNavigationToSymbol(symbol: ts.Symbol): Navigation { const declaration: ts.Declaration | undefined = TypeScriptHelpers.tryGetADeclaration(symbol); const sourceFile: ts.SourceFile | undefined = declaration?.getSourceFile(); @@ -102,11 +70,7 @@ export class DeclarationReferenceGenerator { const isFromExternalLibrary: boolean = !!sourceFile && this._collector.program.isSourceFileFromExternalLibrary(sourceFile); if (isGlobal || isFromExternalLibrary) { - if ( - parent && - parent.members && - DeclarationReferenceGenerator._isSameSymbol(parent.members.get(symbol.escapedName), symbol) - ) { + if (parent && parent.members && _isSameSymbol(parent.members.get(symbol.escapedName), symbol)) { return Navigation.Members; } @@ -125,11 +89,8 @@ export class DeclarationReferenceGenerator { // If its parent symbol is not a source file, then use either Exports or Members. If the parent symbol // is a source file, but it wasn't exported from the package entry point (in the check above), then the // symbol is a local, so fall through below. - if (parent && !DeclarationReferenceGenerator._isExternalModuleSymbol(parent)) { - if ( - parent.members && - DeclarationReferenceGenerator._isSameSymbol(parent.members.get(symbol.escapedName), symbol) - ) { + if (parent && !_isExternalModuleSymbol(parent)) { + if (parent.members && _isSameSymbol(parent.members.get(symbol.escapedName), symbol)) { return Navigation.Members; } @@ -143,55 +104,6 @@ export class DeclarationReferenceGenerator { return Navigation.Locals; } - private static _getMeaningOfSymbol(symbol: ts.Symbol, meaning: ts.SymbolFlags): Meaning | undefined { - if (symbol.flags & meaning & ts.SymbolFlags.Class) { - return Meaning.Class; - } - if (symbol.flags & meaning & ts.SymbolFlags.Enum) { - return Meaning.Enum; - } - if (symbol.flags & meaning & ts.SymbolFlags.Interface) { - return Meaning.Interface; - } - if (symbol.flags & meaning & ts.SymbolFlags.TypeAlias) { - return Meaning.TypeAlias; - } - if (symbol.flags & meaning & ts.SymbolFlags.Function) { - return Meaning.Function; - } - if (symbol.flags & meaning & ts.SymbolFlags.Variable) { - return Meaning.Variable; - } - if (symbol.flags & meaning & ts.SymbolFlags.Module) { - return Meaning.Namespace; - } - if (symbol.flags & meaning & ts.SymbolFlags.ClassMember) { - return Meaning.Member; - } - if (symbol.flags & meaning & ts.SymbolFlags.Constructor) { - return Meaning.Constructor; - } - if (symbol.flags & meaning & ts.SymbolFlags.EnumMember) { - return Meaning.Member; - } - if (symbol.flags & meaning & ts.SymbolFlags.Signature) { - if (symbol.escapedName === ts.InternalSymbolName.Call) { - return Meaning.CallSignature; - } - if (symbol.escapedName === ts.InternalSymbolName.New) { - return Meaning.ConstructSignature; - } - if (symbol.escapedName === ts.InternalSymbolName.Index) { - return Meaning.IndexSignature; - } - } - if (symbol.flags & meaning & ts.SymbolFlags.TypeParameter) { - // This should have already been handled in `getDeclarationReferenceOfSymbol`. - throw new InternalError('Not supported.'); - } - return undefined; - } - private _symbolToDeclarationReference( symbol: ts.Symbol, meaning: ts.SymbolFlags, @@ -214,7 +126,7 @@ export class DeclarationReferenceGenerator { } } - if (DeclarationReferenceGenerator._isExternalModuleSymbol(followedSymbol)) { + if (_isExternalModuleSymbol(followedSymbol)) { if (!includeModuleSymbols) { return undefined; } @@ -270,7 +182,7 @@ export class DeclarationReferenceGenerator { return parentRef .addNavigationStep(navigation, localName) - .withMeaning(DeclarationReferenceGenerator._getMeaningOfSymbol(followedSymbol, meaning)); + .withMeaning(_getMeaningOfSymbol(followedSymbol, meaning)); } private _getParentReference(symbol: ts.Symbol): DeclarationReference | undefined { @@ -380,3 +292,84 @@ export class DeclarationReferenceGenerator { return GlobalSource.instance; } } + +function _isInExpressionContext(node: ts.Node): boolean { + switch (node.parent.kind) { + case ts.SyntaxKind.TypeQuery: + case ts.SyntaxKind.ComputedPropertyName: + return true; + case ts.SyntaxKind.QualifiedName: + return _isInExpressionContext(node.parent); + default: + return false; + } +} + +function _isExternalModuleSymbol(symbol: ts.Symbol): boolean { + return ( + !!(symbol.flags & ts.SymbolFlags.ValueModule) && + symbol.valueDeclaration !== undefined && + ts.isSourceFile(symbol.valueDeclaration) + ); +} + +function _isSameSymbol(left: ts.Symbol | undefined, right: ts.Symbol): boolean { + return ( + left === right || + !!( + left && + left.valueDeclaration && + right.valueDeclaration && + left.valueDeclaration === right.valueDeclaration + ) + ); +} + +function _getMeaningOfSymbol(symbol: ts.Symbol, meaning: ts.SymbolFlags): Meaning | undefined { + if (symbol.flags & meaning & ts.SymbolFlags.Class) { + return Meaning.Class; + } + if (symbol.flags & meaning & ts.SymbolFlags.Enum) { + return Meaning.Enum; + } + if (symbol.flags & meaning & ts.SymbolFlags.Interface) { + return Meaning.Interface; + } + if (symbol.flags & meaning & ts.SymbolFlags.TypeAlias) { + return Meaning.TypeAlias; + } + if (symbol.flags & meaning & ts.SymbolFlags.Function) { + return Meaning.Function; + } + if (symbol.flags & meaning & ts.SymbolFlags.Variable) { + return Meaning.Variable; + } + if (symbol.flags & meaning & ts.SymbolFlags.Module) { + return Meaning.Namespace; + } + if (symbol.flags & meaning & ts.SymbolFlags.ClassMember) { + return Meaning.Member; + } + if (symbol.flags & meaning & ts.SymbolFlags.Constructor) { + return Meaning.Constructor; + } + if (symbol.flags & meaning & ts.SymbolFlags.EnumMember) { + return Meaning.Member; + } + if (symbol.flags & meaning & ts.SymbolFlags.Signature) { + if (symbol.escapedName === ts.InternalSymbolName.Call) { + return Meaning.CallSignature; + } + if (symbol.escapedName === ts.InternalSymbolName.New) { + return Meaning.ConstructSignature; + } + if (symbol.escapedName === ts.InternalSymbolName.Index) { + return Meaning.IndexSignature; + } + } + if (symbol.flags & meaning & ts.SymbolFlags.TypeParameter) { + // This should have already been handled in `getDeclarationReferenceOfSymbol`. + throw new InternalError('Not supported.'); + } + return undefined; +} diff --git a/apps/api-extractor/src/generators/DtsRollupGenerator.ts b/apps/api-extractor/src/generators/DtsRollupGenerator.ts index cf2cc13300b..519e3b7efd5 100644 --- a/apps/api-extractor/src/generators/DtsRollupGenerator.ts +++ b/apps/api-extractor/src/generators/DtsRollupGenerator.ts @@ -71,428 +71,427 @@ export class DtsRollupGenerator { const writer: IndentedWriter = new IndentedWriter(); writer.trimLeadingSpaces = true; - DtsRollupGenerator._generateTypingsFileContent(collector, writer, dtsKind); + _generateTypingsFileContent(collector, writer, dtsKind); FileSystem.writeFile(dtsFilename, writer.toString(), { convertLineEndings: newlineKind, ensureFolderExists: true }); } +} - private static _generateTypingsFileContent( - collector: Collector, - writer: IndentedWriter, - dtsKind: DtsRollupKind - ): void { - // Emit the @packageDocumentation comment at the top of the file - if (collector.workingPackage.tsdocParserContext) { - writer.trimLeadingSpaces = false; - writer.writeLine(collector.workingPackage.tsdocParserContext.sourceRange.toString()); - writer.trimLeadingSpaces = true; - writer.ensureSkippedLine(); - } - - // Emit the triple slash directives - for (const typeDirectiveReference of collector.dtsTypeReferenceDirectives) { - // https://github.com/microsoft/TypeScript/blob/611ebc7aadd7a44a4c0447698bfda9222a78cb66/src/compiler/declarationEmitter.ts#L162 - writer.writeLine(`/// `); - } - for (const libDirectiveReference of collector.dtsLibReferenceDirectives) { - writer.writeLine(`/// `); - } +function _generateTypingsFileContent( + collector: Collector, + writer: IndentedWriter, + dtsKind: DtsRollupKind +): void { + // Emit the @packageDocumentation comment at the top of the file + if (collector.workingPackage.tsdocParserContext) { + writer.trimLeadingSpaces = false; + writer.writeLine(collector.workingPackage.tsdocParserContext.sourceRange.toString()); + writer.trimLeadingSpaces = true; writer.ensureSkippedLine(); + } - // Emit the imports - for (const entity of collector.entities) { - if (entity.astEntity instanceof AstImport) { - // Note: it isn't valid to trim imports based on their release tags. - // E.g. class Foo (`@public`) extends interface Bar (`@beta`) from some external library. - // API-Extractor cannot trim `import { Bar } from "external-library"` when generating its public rollup, - // or the export of `Foo` would include a broken reference to `Bar`. - const astImport: AstImport = entity.astEntity; - DtsEmitHelpers.emitImport(writer, entity, astImport); + // Emit the triple slash directives + for (const typeDirectiveReference of collector.dtsTypeReferenceDirectives) { + // https://github.com/microsoft/TypeScript/blob/611ebc7aadd7a44a4c0447698bfda9222a78cb66/src/compiler/declarationEmitter.ts#L162 + writer.writeLine(`/// `); + } + for (const libDirectiveReference of collector.dtsLibReferenceDirectives) { + writer.writeLine(`/// `); + } + writer.ensureSkippedLine(); + + // Emit the imports + for (const entity of collector.entities) { + if (entity.astEntity instanceof AstImport) { + // Note: it isn't valid to trim imports based on their release tags. + // E.g. class Foo (`@public`) extends interface Bar (`@beta`) from some external library. + // API-Extractor cannot trim `import { Bar } from "external-library"` when generating its public rollup, + // or the export of `Foo` would include a broken reference to `Bar`. + const astImport: AstImport = entity.astEntity; + DtsEmitHelpers.emitImport(writer, entity, astImport); + } + } + writer.ensureSkippedLine(); + + // Emit the regular declarations + for (const entity of collector.entities) { + const astEntity: AstEntity = entity.astEntity; + const symbolMetadata: SymbolMetadata | undefined = collector.tryFetchMetadataForAstEntity(astEntity); + const maxEffectiveReleaseTag: ReleaseTag = symbolMetadata + ? symbolMetadata.maxEffectiveReleaseTag + : ReleaseTag.None; + + if (!_shouldIncludeReleaseTag(maxEffectiveReleaseTag, dtsKind)) { + if (!collector.extractorConfig.omitTrimmingComments) { + writer.ensureSkippedLine(); + writer.writeLine(`/* Excluded from this release type: ${entity.nameForEmit} */`); } + continue; } - writer.ensureSkippedLine(); - // Emit the regular declarations - for (const entity of collector.entities) { - const astEntity: AstEntity = entity.astEntity; - const symbolMetadata: SymbolMetadata | undefined = collector.tryFetchMetadataForAstEntity(astEntity); - const maxEffectiveReleaseTag: ReleaseTag = symbolMetadata - ? symbolMetadata.maxEffectiveReleaseTag - : ReleaseTag.None; + if (astEntity instanceof AstSymbol) { + // Emit all the declarations for this entry + for (const astDeclaration of astEntity.astDeclarations || []) { + const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); - if (!this._shouldIncludeReleaseTag(maxEffectiveReleaseTag, dtsKind)) { - if (!collector.extractorConfig.omitTrimmingComments) { + if (!_shouldIncludeReleaseTag(apiItemMetadata.effectiveReleaseTag, dtsKind)) { + if (!collector.extractorConfig.omitTrimmingComments) { + writer.ensureSkippedLine(); + writer.writeLine(`/* Excluded declaration from this release type: ${entity.nameForEmit} */`); + } + continue; + } else { + const span: Span = new Span(astDeclaration.declaration); + _modifySpan(collector, span, entity, astDeclaration, dtsKind); writer.ensureSkippedLine(); - writer.writeLine(`/* Excluded from this release type: ${entity.nameForEmit} */`); + span.writeModifiedText(writer); + writer.ensureNewLine(); } - continue; } + } - if (astEntity instanceof AstSymbol) { - // Emit all the declarations for this entry - for (const astDeclaration of astEntity.astDeclarations || []) { - const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration); + if (astEntity instanceof AstNamespaceImport) { + const astModuleExportInfo: IAstModuleExportInfo = astEntity.fetchAstModuleExportInfo(collector); - if (!this._shouldIncludeReleaseTag(apiItemMetadata.effectiveReleaseTag, dtsKind)) { - if (!collector.extractorConfig.omitTrimmingComments) { - writer.ensureSkippedLine(); - writer.writeLine(`/* Excluded declaration from this release type: ${entity.nameForEmit} */`); - } - continue; - } else { - const span: Span = new Span(astDeclaration.declaration); - DtsRollupGenerator._modifySpan(collector, span, entity, astDeclaration, dtsKind); - writer.ensureSkippedLine(); - span.writeModifiedText(writer); - writer.ensureNewLine(); - } - } + if (entity.nameForEmit === undefined) { + // This should never happen + throw new InternalError('referencedEntry.nameForEmit is undefined'); } - if (astEntity instanceof AstNamespaceImport) { - const astModuleExportInfo: IAstModuleExportInfo = astEntity.fetchAstModuleExportInfo(collector); + if (astModuleExportInfo.starExportedExternalModules.size > 0) { + // We could support this, but we would need to find a way to safely represent it. + throw new Error( + `The ${entity.nameForEmit} namespace import includes a start export, which is not supported:\n` + + SourceFileLocationFormatter.formatDeclaration(astEntity.declaration) + ); + } - if (entity.nameForEmit === undefined) { - // This should never happen - throw new InternalError('referencedEntry.nameForEmit is undefined'); - } + // Emit a synthetic declaration for the namespace. It will look like this: + // + // declare namespace example { + // export { + // f1, + // f2 + // } + // } + // + // Note that we do not try to relocate f1()/f2() to be inside the namespace because other type + // signatures may reference them directly (without using the namespace qualifier). - if (astModuleExportInfo.starExportedExternalModules.size > 0) { - // We could support this, but we would need to find a way to safely represent it. - throw new Error( - `The ${entity.nameForEmit} namespace import includes a start export, which is not supported:\n` + - SourceFileLocationFormatter.formatDeclaration(astEntity.declaration) - ); - } + writer.ensureSkippedLine(); + if (entity.shouldInlineExport) { + writer.write('export '); + } + writer.writeLine(`declare namespace ${entity.nameForEmit} {`); - // Emit a synthetic declaration for the namespace. It will look like this: - // - // declare namespace example { - // export { - // f1, - // f2 - // } - // } - // - // Note that we do not try to relocate f1()/f2() to be inside the namespace because other type - // signatures may reference them directly (without using the namespace qualifier). + // all local exports of local imported module are just references to top-level declarations + writer.increaseIndent(); + writer.writeLine('export {'); + writer.increaseIndent(); - writer.ensureSkippedLine(); - if (entity.shouldInlineExport) { - writer.write('export '); + const exportClauses: string[] = []; + for (const [exportedName, exportedEntity] of astModuleExportInfo.exportedLocalEntities) { + const collectorEntity: CollectorEntity | undefined = collector.tryGetCollectorEntity(exportedEntity); + if (collectorEntity === undefined) { + // This should never happen + // top-level exports of local imported module should be added as collector entities before + throw new InternalError( + `Cannot find collector entity for ${entity.nameForEmit}.${exportedEntity.localName}` + ); } - writer.writeLine(`declare namespace ${entity.nameForEmit} {`); - - // all local exports of local imported module are just references to top-level declarations - writer.increaseIndent(); - writer.writeLine('export {'); - writer.increaseIndent(); - - const exportClauses: string[] = []; - for (const [exportedName, exportedEntity] of astModuleExportInfo.exportedLocalEntities) { - const collectorEntity: CollectorEntity | undefined = - collector.tryGetCollectorEntity(exportedEntity); - if (collectorEntity === undefined) { - // This should never happen - // top-level exports of local imported module should be added as collector entities before - throw new InternalError( - `Cannot find collector entity for ${entity.nameForEmit}.${exportedEntity.localName}` - ); - } - // If the entity's declaration won't be included, then neither should the namespace export it - // This fixes the issue encountered here: https://github.com/microsoft/rushstack/issues/2791 - const exportedSymbolMetadata: SymbolMetadata | undefined = - collector.tryFetchMetadataForAstEntity(exportedEntity); - const exportedMaxEffectiveReleaseTag: ReleaseTag = exportedSymbolMetadata - ? exportedSymbolMetadata.maxEffectiveReleaseTag - : ReleaseTag.None; - if (!this._shouldIncludeReleaseTag(exportedMaxEffectiveReleaseTag, dtsKind)) { - continue; - } - - if (collectorEntity.nameForEmit === exportedName) { - exportClauses.push(collectorEntity.nameForEmit); - } else { - const safeExportedName: string = SyntaxHelpers.isSafeUnquotedMemberIdentifier(exportedName) - ? exportedName - : JSON.stringify(exportedName); - exportClauses.push(`${collectorEntity.nameForEmit} as ${safeExportedName}`); - } + // If the entity's declaration won't be included, then neither should the namespace export it + // This fixes the issue encountered here: https://github.com/microsoft/rushstack/issues/2791 + const exportedSymbolMetadata: SymbolMetadata | undefined = + collector.tryFetchMetadataForAstEntity(exportedEntity); + const exportedMaxEffectiveReleaseTag: ReleaseTag = exportedSymbolMetadata + ? exportedSymbolMetadata.maxEffectiveReleaseTag + : ReleaseTag.None; + if (!_shouldIncludeReleaseTag(exportedMaxEffectiveReleaseTag, dtsKind)) { + continue; } - writer.writeLine(exportClauses.join(',\n')); - - writer.decreaseIndent(); - writer.writeLine('}'); // end of "export { ... }" - writer.decreaseIndent(); - writer.writeLine('}'); // end of "declare namespace { ... }" - } - if (!entity.shouldInlineExport) { - for (const exportName of entity.exportNames) { - DtsEmitHelpers.emitNamedExport(writer, exportName, entity); + if (collectorEntity.nameForEmit === exportedName) { + exportClauses.push(collectorEntity.nameForEmit); + } else { + const safeExportedName: string = SyntaxHelpers.isSafeUnquotedMemberIdentifier(exportedName) + ? exportedName + : JSON.stringify(exportedName); + exportClauses.push(`${collectorEntity.nameForEmit} as ${safeExportedName}`); } } + writer.writeLine(exportClauses.join(',\n')); - writer.ensureSkippedLine(); + writer.decreaseIndent(); + writer.writeLine('}'); // end of "export { ... }" + writer.decreaseIndent(); + writer.writeLine('}'); // end of "declare namespace { ... }" } - DtsEmitHelpers.emitStarExports(writer, collector); + if (!entity.shouldInlineExport) { + for (const exportName of entity.exportNames) { + DtsEmitHelpers.emitNamedExport(writer, exportName, entity); + } + } - // Emit "export { }" which is a special directive that prevents consumers from importing declarations - // that don't have an explicit "export" modifier. writer.ensureSkippedLine(); - writer.writeLine('export { }'); } - /** - * Before writing out a declaration, _modifySpan() applies various fixups to make it nice. - */ - private static _modifySpan( - collector: Collector, - span: Span, - entity: CollectorEntity, - astDeclaration: AstDeclaration, - dtsKind: DtsRollupKind - ): void { - const previousSpan: Span | undefined = span.previousSibling; - - let recurseChildren: boolean = true; - switch (span.kind) { - case ts.SyntaxKind.JSDocComment: - // If the @packageDocumentation comment seems to be attached to one of the regular API items, - // omit it. It gets explictly emitted at the top of the file. - if (span.node.getText().match(/(?:\s|\*)@packageDocumentation(?:\s|\*)/gi)) { - span.modification.skipAll(); - } + DtsEmitHelpers.emitStarExports(writer, collector); - // For now, we don't transform JSDoc comment nodes at all - recurseChildren = false; - break; + // Emit "export { }" which is a special directive that prevents consumers from importing declarations + // that don't have an explicit "export" modifier. + writer.ensureSkippedLine(); + writer.writeLine('export { }'); +} - case ts.SyntaxKind.ExportKeyword: - if (DtsEmitHelpers.isExportKeywordInNamespaceExportDeclaration(span.node)) { - // This is an export declaration inside a namespace - preserve the export keyword - break; - } - // Otherwise, delete the export keyword -- we will re-add it below +/** + * Before writing out a declaration, _modifySpan() applies various fixups to make it nice. + */ +function _modifySpan( + collector: Collector, + span: Span, + entity: CollectorEntity, + astDeclaration: AstDeclaration, + dtsKind: DtsRollupKind +): void { + const previousSpan: Span | undefined = span.previousSibling; + + let recurseChildren: boolean = true; + switch (span.kind) { + case ts.SyntaxKind.JSDocComment: + // If the @packageDocumentation comment seems to be attached to one of the regular API items, + // omit it. It gets explictly emitted at the top of the file. + if (span.node.getText().match(/(?:\s|\*)@packageDocumentation(?:\s|\*)/gi)) { span.modification.skipAll(); - break; + } - case ts.SyntaxKind.DefaultKeyword: - case ts.SyntaxKind.DeclareKeyword: - // Delete any explicit "export" or "declare" keywords -- we will re-add them below - span.modification.skipAll(); + // For now, we don't transform JSDoc comment nodes at all + recurseChildren = false; + break; + + case ts.SyntaxKind.ExportKeyword: + if (DtsEmitHelpers.isExportKeywordInNamespaceExportDeclaration(span.node)) { + // This is an export declaration inside a namespace - preserve the export keyword break; + } + // Otherwise, delete the export keyword -- we will re-add it below + span.modification.skipAll(); + break; + + case ts.SyntaxKind.DefaultKeyword: + case ts.SyntaxKind.DeclareKeyword: + // Delete any explicit "export" or "declare" keywords -- we will re-add them below + span.modification.skipAll(); + break; + + case ts.SyntaxKind.InterfaceKeyword: + case ts.SyntaxKind.ClassKeyword: + case ts.SyntaxKind.EnumKeyword: + case ts.SyntaxKind.NamespaceKeyword: + case ts.SyntaxKind.ModuleKeyword: + case ts.SyntaxKind.TypeKeyword: + case ts.SyntaxKind.FunctionKeyword: + // Replace the stuff we possibly deleted above + let replacedModifiers: string = ''; + + // Add a declare statement for root declarations (but not for nested declarations) + if (!astDeclaration.parent) { + replacedModifiers += 'declare '; + } - case ts.SyntaxKind.InterfaceKeyword: - case ts.SyntaxKind.ClassKeyword: - case ts.SyntaxKind.EnumKeyword: - case ts.SyntaxKind.NamespaceKeyword: - case ts.SyntaxKind.ModuleKeyword: - case ts.SyntaxKind.TypeKeyword: - case ts.SyntaxKind.FunctionKeyword: - // Replace the stuff we possibly deleted above - let replacedModifiers: string = ''; - - // Add a declare statement for root declarations (but not for nested declarations) - if (!astDeclaration.parent) { - replacedModifiers += 'declare '; - } + if (entity.shouldInlineExport) { + replacedModifiers = 'export ' + replacedModifiers; + } - if (entity.shouldInlineExport) { - replacedModifiers = 'export ' + replacedModifiers; + if (previousSpan && previousSpan.kind === ts.SyntaxKind.SyntaxList) { + // If there is a previous span of type SyntaxList, then apply it before any other modifiers + // (e.g. "abstract") that appear there. + previousSpan.modification.prefix = replacedModifiers + previousSpan.modification.prefix; + } else { + // Otherwise just stick it in front of this span + span.modification.prefix = replacedModifiers + span.modification.prefix; + } + break; + + case ts.SyntaxKind.VariableDeclaration: + // Is this a top-level variable declaration? + // (The logic below does not apply to variable declarations that are part of an explicit "namespace" block, + // since the compiler prefers not to emit "declare" or "export" keywords for those declarations.) + if (!span.parent) { + // The VariableDeclaration node is part of a VariableDeclarationList, however + // the Entry.followedSymbol points to the VariableDeclaration part because + // multiple definitions might share the same VariableDeclarationList. + // + // Since we are emitting a separate declaration for each one, we need to look upwards + // in the ts.Node tree and write a copy of the enclosing VariableDeclarationList + // content (e.g. "var" from "var x=1, y=2"). + const list: ts.VariableDeclarationList | undefined = TypeScriptHelpers.matchAncestor(span.node, [ + ts.SyntaxKind.VariableDeclarationList, + ts.SyntaxKind.VariableDeclaration + ]); + if (!list) { + // This should not happen unless the compiler API changes somehow + throw new InternalError('Unsupported variable declaration'); } + const listPrefix: string = list + .getSourceFile() + .text.substring(list.getStart(), list.declarations[0].getStart()); + span.modification.prefix = 'declare ' + listPrefix + span.modification.prefix; + span.modification.suffix = ';'; - if (previousSpan && previousSpan.kind === ts.SyntaxKind.SyntaxList) { - // If there is a previous span of type SyntaxList, then apply it before any other modifiers - // (e.g. "abstract") that appear there. - previousSpan.modification.prefix = replacedModifiers + previousSpan.modification.prefix; - } else { - // Otherwise just stick it in front of this span - span.modification.prefix = replacedModifiers + span.modification.prefix; + if (entity.shouldInlineExport) { + span.modification.prefix = 'export ' + span.modification.prefix; } - break; - - case ts.SyntaxKind.VariableDeclaration: - // Is this a top-level variable declaration? - // (The logic below does not apply to variable declarations that are part of an explicit "namespace" block, - // since the compiler prefers not to emit "declare" or "export" keywords for those declarations.) - if (!span.parent) { - // The VariableDeclaration node is part of a VariableDeclarationList, however - // the Entry.followedSymbol points to the VariableDeclaration part because - // multiple definitions might share the same VariableDeclarationList. - // - // Since we are emitting a separate declaration for each one, we need to look upwards - // in the ts.Node tree and write a copy of the enclosing VariableDeclarationList - // content (e.g. "var" from "var x=1, y=2"). - const list: ts.VariableDeclarationList | undefined = TypeScriptHelpers.matchAncestor(span.node, [ - ts.SyntaxKind.VariableDeclarationList, - ts.SyntaxKind.VariableDeclaration - ]); - if (!list) { - // This should not happen unless the compiler API changes somehow - throw new InternalError('Unsupported variable declaration'); - } - const listPrefix: string = list - .getSourceFile() - .text.substring(list.getStart(), list.declarations[0].getStart()); - span.modification.prefix = 'declare ' + listPrefix + span.modification.prefix; - span.modification.suffix = ';'; - - if (entity.shouldInlineExport) { - span.modification.prefix = 'export ' + span.modification.prefix; - } - const declarationMetadata: DeclarationMetadata = collector.fetchDeclarationMetadata(astDeclaration); - if (declarationMetadata.tsdocParserContext) { - // Typically the comment for a variable declaration is attached to the outer variable statement - // (which may possibly contain multiple variable declarations), so it's not part of the Span. - // Instead we need to manually inject it. - let originalComment: string = declarationMetadata.tsdocParserContext.sourceRange.toString(); - if (!/\r?\n\s*$/.test(originalComment)) { - originalComment += '\n'; - } - span.modification.indentDocComment = IndentDocCommentScope.PrefixOnly; - span.modification.prefix = originalComment + span.modification.prefix; + const declarationMetadata: DeclarationMetadata = collector.fetchDeclarationMetadata(astDeclaration); + if (declarationMetadata.tsdocParserContext) { + // Typically the comment for a variable declaration is attached to the outer variable statement + // (which may possibly contain multiple variable declarations), so it's not part of the Span. + // Instead we need to manually inject it. + let originalComment: string = declarationMetadata.tsdocParserContext.sourceRange.toString(); + if (!/\r?\n\s*$/.test(originalComment)) { + originalComment += '\n'; } + span.modification.indentDocComment = IndentDocCommentScope.PrefixOnly; + span.modification.prefix = originalComment + span.modification.prefix; } - break; - - case ts.SyntaxKind.Identifier: - { - const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode( - span.node as ts.Identifier - ); + } + break; - if (referencedEntity) { - if (!referencedEntity.nameForEmit) { - // This should never happen - throw new InternalError('referencedEntry.nameForEmit is undefined'); - } + case ts.SyntaxKind.Identifier: + { + const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode( + span.node as ts.Identifier + ); - span.modification.prefix = referencedEntity.nameForEmit; - // For debugging: - // span.modification.prefix += '/*R=FIX*/'; - } else { - // For debugging: - // span.modification.prefix += '/*R=KEEP*/'; + if (referencedEntity) { + if (!referencedEntity.nameForEmit) { + // This should never happen + throw new InternalError('referencedEntry.nameForEmit is undefined'); } + + span.modification.prefix = referencedEntity.nameForEmit; + // For debugging: + // span.modification.prefix += '/*R=FIX*/'; + } else { + // For debugging: + // span.modification.prefix += '/*R=KEEP*/'; } - break; + } + break; + + case ts.SyntaxKind.ImportType: + DtsEmitHelpers.modifyImportTypeSpan( + collector, + span, + astDeclaration, + (childSpan, childAstDeclaration) => { + _modifySpan(collector, childSpan, entity, childAstDeclaration, dtsKind); + } + ); + break; + } - case ts.SyntaxKind.ImportType: - DtsEmitHelpers.modifyImportTypeSpan( - collector, - span, - astDeclaration, - (childSpan, childAstDeclaration) => { - DtsRollupGenerator._modifySpan(collector, childSpan, entity, childAstDeclaration, dtsKind); - } + if (recurseChildren) { + for (const child of span.children) { + let childAstDeclaration: AstDeclaration = astDeclaration; + + // Should we trim this node? + let trimmed: boolean = false; + if (AstDeclaration.isSupportedSyntaxKind(child.kind)) { + childAstDeclaration = collector.astSymbolTable.getChildAstDeclarationByNode( + child.node, + astDeclaration ); - break; - } + const releaseTag: ReleaseTag = + collector.fetchApiItemMetadata(childAstDeclaration).effectiveReleaseTag; - if (recurseChildren) { - for (const child of span.children) { - let childAstDeclaration: AstDeclaration = astDeclaration; + if (!_shouldIncludeReleaseTag(releaseTag, dtsKind)) { + let nodeToTrim: Span = child; - // Should we trim this node? - let trimmed: boolean = false; - if (AstDeclaration.isSupportedSyntaxKind(child.kind)) { - childAstDeclaration = collector.astSymbolTable.getChildAstDeclarationByNode( - child.node, - astDeclaration - ); - const releaseTag: ReleaseTag = - collector.fetchApiItemMetadata(childAstDeclaration).effectiveReleaseTag; - - if (!this._shouldIncludeReleaseTag(releaseTag, dtsKind)) { - let nodeToTrim: Span = child; - - // If we are trimming a variable statement, then we need to trim the outer VariableDeclarationList - // as well. - if (child.kind === ts.SyntaxKind.VariableDeclaration) { - const variableStatement: Span | undefined = child.findFirstParent( - ts.SyntaxKind.VariableStatement - ); - if (variableStatement !== undefined) { - nodeToTrim = variableStatement; - } + // If we are trimming a variable statement, then we need to trim the outer VariableDeclarationList + // as well. + if (child.kind === ts.SyntaxKind.VariableDeclaration) { + const variableStatement: Span | undefined = child.findFirstParent( + ts.SyntaxKind.VariableStatement + ); + if (variableStatement !== undefined) { + nodeToTrim = variableStatement; } + } - const modification: SpanModification = nodeToTrim.modification; - - // Yes, trim it and stop here - const name: string = childAstDeclaration.astSymbol.localName; - modification.omitChildren = true; + const modification: SpanModification = nodeToTrim.modification; - if (!collector.extractorConfig.omitTrimmingComments) { - modification.prefix = `/* Excluded from this release type: ${name} */`; - } else { - modification.prefix = ''; - } - modification.suffix = ''; + // Yes, trim it and stop here + const name: string = childAstDeclaration.astSymbol.localName; + modification.omitChildren = true; - if (nodeToTrim.children.length > 0) { - // If there are grandchildren, then keep the last grandchild's separator, - // since it often has useful whitespace - modification.suffix = nodeToTrim.children[nodeToTrim.children.length - 1].separator; - } + if (!collector.extractorConfig.omitTrimmingComments) { + modification.prefix = `/* Excluded from this release type: ${name} */`; + } else { + modification.prefix = ''; + } + modification.suffix = ''; - if (nodeToTrim.nextSibling) { - // If the thing we are trimming is followed by a comma, then trim the comma also. - // An example would be an enum member. - if (nodeToTrim.nextSibling.kind === ts.SyntaxKind.CommaToken) { - // Keep its separator since it often has useful whitespace - modification.suffix += nodeToTrim.nextSibling.separator; - nodeToTrim.nextSibling.modification.skipAll(); - } - } + if (nodeToTrim.children.length > 0) { + // If there are grandchildren, then keep the last grandchild's separator, + // since it often has useful whitespace + modification.suffix = nodeToTrim.children[nodeToTrim.children.length - 1].separator; + } - if (modification.suffix.trim().length === 0 && modification.prefix.trim().length === 0) { - // In case of blank prefix and suffix, remove indentation to avoid blank lines in place of removed members - modification.suffix = ''; - modification.prefix = ''; + if (nodeToTrim.nextSibling) { + // If the thing we are trimming is followed by a comma, then trim the comma also. + // An example would be an enum member. + if (nodeToTrim.nextSibling.kind === ts.SyntaxKind.CommaToken) { + // Keep its separator since it often has useful whitespace + modification.suffix += nodeToTrim.nextSibling.separator; + nodeToTrim.nextSibling.modification.skipAll(); } + } - trimmed = true; + if (modification.suffix.trim().length === 0 && modification.prefix.trim().length === 0) { + // In case of blank prefix and suffix, remove indentation to avoid blank lines in place of removed members + modification.suffix = ''; + modification.prefix = ''; } - } - if (!trimmed) { - DtsRollupGenerator._modifySpan(collector, child, entity, childAstDeclaration, dtsKind); + trimmed = true; } } + + if (!trimmed) { + _modifySpan(collector, child, entity, childAstDeclaration, dtsKind); + } } } +} - private static _shouldIncludeReleaseTag(releaseTag: ReleaseTag, dtsKind: DtsRollupKind): boolean { - switch (dtsKind) { - case DtsRollupKind.InternalRelease: - return true; - case DtsRollupKind.AlphaRelease: - return ( - releaseTag === ReleaseTag.Alpha || - releaseTag === ReleaseTag.Beta || - releaseTag === ReleaseTag.Public || - // NOTE: If the release tag is "None", then we don't have enough information to trim it - releaseTag === ReleaseTag.None - ); - case DtsRollupKind.BetaRelease: - return ( - releaseTag === ReleaseTag.Beta || - releaseTag === ReleaseTag.Public || - // NOTE: If the release tag is "None", then we don't have enough information to trim it - releaseTag === ReleaseTag.None - ); - case DtsRollupKind.PublicRelease: - return releaseTag === ReleaseTag.Public || releaseTag === ReleaseTag.None; - default: - throw new Error(`${DtsRollupKind[dtsKind]} is not implemented`); - } +function _shouldIncludeReleaseTag(releaseTag: ReleaseTag, dtsKind: DtsRollupKind): boolean { + switch (dtsKind) { + case DtsRollupKind.InternalRelease: + return true; + case DtsRollupKind.AlphaRelease: + return ( + releaseTag === ReleaseTag.Alpha || + releaseTag === ReleaseTag.Beta || + releaseTag === ReleaseTag.Public || + // NOTE: If the release tag is "None", then we don't have enough information to trim it + releaseTag === ReleaseTag.None + ); + case DtsRollupKind.BetaRelease: + return ( + releaseTag === ReleaseTag.Beta || + releaseTag === ReleaseTag.Public || + // NOTE: If the release tag is "None", then we don't have enough information to trim it + releaseTag === ReleaseTag.None + ); + case DtsRollupKind.PublicRelease: + return releaseTag === ReleaseTag.Public || releaseTag === ReleaseTag.None; + default: + throw new Error(`${DtsRollupKind[dtsKind]} is not implemented`); } } diff --git a/apps/api-extractor/src/generators/ExcerptBuilder.ts b/apps/api-extractor/src/generators/ExcerptBuilder.ts index ce1662c789a..b1fc1056d3b 100644 --- a/apps/api-extractor/src/generators/ExcerptBuilder.ts +++ b/apps/api-extractor/src/generators/ExcerptBuilder.ts @@ -120,7 +120,7 @@ export class ExcerptBuilder { } } - ExcerptBuilder._buildSpan(excerptTokens, span, { + _buildSpan(excerptTokens, span, { referenceGenerator: referenceGenerator, startingNode: span.node, stopBeforeChildKind, @@ -128,245 +128,240 @@ export class ExcerptBuilder { lastAppendedTokenIsSeparator: false }); - ExcerptBuilder._condenseTokens(excerptTokens, captureTokenRanges); + _condenseTokens(excerptTokens, captureTokenRanges); } public static createEmptyTokenRange(): IExcerptTokenRange { return { startIndex: 0, endIndex: 0 }; } +} - /** @returns false if we encountered a token that causes iteration to stop. */ - private static _buildSpan(excerptTokens: IExcerptToken[], span: Span, state: IBuildSpanState): boolean { - if (span.kind === ts.SyntaxKind.JSDocComment) { - // Discard any comments - return true; - } +/** @returns false if we encountered a token that causes iteration to stop. */ +function _buildSpan(excerptTokens: IExcerptToken[], span: Span, state: IBuildSpanState): boolean { + if (span.kind === ts.SyntaxKind.JSDocComment) { + // Discard any comments + return true; + } - // Can this node start a excerpt? - const transform: IExcerptBuilderNodeTransform | undefined = state.transformsByNode.get(span.node); + // Can this node start a excerpt? + const transform: IExcerptBuilderNodeTransform | undefined = state.transformsByNode.get(span.node); - let captureTokenRange: IExcerptTokenRange | undefined = undefined; + let captureTokenRange: IExcerptTokenRange | undefined = undefined; - if (transform) { - captureTokenRange = transform.captureTokenRange; - if (transform.replacementText !== undefined) { - excerptTokens.push({ - kind: ExcerptTokenKind.Content, - text: transform.replacementText - }); - state.lastAppendedTokenIsSeparator = false; + if (transform) { + captureTokenRange = transform.captureTokenRange; + if (transform.replacementText !== undefined) { + excerptTokens.push({ + kind: ExcerptTokenKind.Content, + text: transform.replacementText + }); + state.lastAppendedTokenIsSeparator = false; - if (captureTokenRange) { - captureTokenRange.startIndex = excerptTokens.length; - captureTokenRange.endIndex = captureTokenRange.startIndex + 1; - } - return true; + if (captureTokenRange) { + captureTokenRange.startIndex = excerptTokens.length; + captureTokenRange.endIndex = captureTokenRange.startIndex + 1; } + return true; } + } - let excerptStartIndex: number = 0; + let excerptStartIndex: number = 0; - if (captureTokenRange) { - // We will assign capturedTokenRange.startIndex to be the index of the next token to be appended - excerptStartIndex = excerptTokens.length; - } + if (captureTokenRange) { + // We will assign capturedTokenRange.startIndex to be the index of the next token to be appended + excerptStartIndex = excerptTokens.length; + } - if (span.prefix) { - let canonicalReference: DeclarationReference | undefined = undefined; + if (span.prefix) { + let canonicalReference: DeclarationReference | undefined = undefined; - if (span.kind === ts.SyntaxKind.Identifier) { - const name: ts.Identifier = span.node as ts.Identifier; - if (!ExcerptBuilder._isDeclarationName(name)) { - canonicalReference = state.referenceGenerator.getDeclarationReferenceForIdentifier(name); - } - } - - if (canonicalReference) { - ExcerptBuilder._appendToken( - excerptTokens, - ExcerptTokenKind.Reference, - span.prefix, - canonicalReference - ); - } else { - ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Content, span.prefix); + if (span.kind === ts.SyntaxKind.Identifier) { + const name: ts.Identifier = span.node as ts.Identifier; + if (!_isDeclarationName(name)) { + canonicalReference = state.referenceGenerator.getDeclarationReferenceForIdentifier(name); } - state.lastAppendedTokenIsSeparator = false; } - for (const child of span.children) { - if (span.node === state.startingNode) { - if (state.stopBeforeChildKind && child.kind === state.stopBeforeChildKind) { - // We reached a child whose kind is stopBeforeChildKind, so stop traversing - return false; - } - } + if (canonicalReference) { + _appendToken(excerptTokens, ExcerptTokenKind.Reference, span.prefix, canonicalReference); + } else { + _appendToken(excerptTokens, ExcerptTokenKind.Content, span.prefix); + } + state.lastAppendedTokenIsSeparator = false; + } - if (!this._buildSpan(excerptTokens, child, state)) { + for (const child of span.children) { + if (span.node === state.startingNode) { + if (state.stopBeforeChildKind && child.kind === state.stopBeforeChildKind) { + // We reached a child whose kind is stopBeforeChildKind, so stop traversing return false; } } - if (span.suffix) { - ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Content, span.suffix); - state.lastAppendedTokenIsSeparator = false; - } - if (span.separator) { - ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Content, span.separator); - state.lastAppendedTokenIsSeparator = true; + if (!_buildSpan(excerptTokens, child, state)) { + return false; } + } - // Are we building a excerpt? If so, set its range - if (captureTokenRange) { - captureTokenRange.startIndex = excerptStartIndex; + if (span.suffix) { + _appendToken(excerptTokens, ExcerptTokenKind.Content, span.suffix); + state.lastAppendedTokenIsSeparator = false; + } + if (span.separator) { + _appendToken(excerptTokens, ExcerptTokenKind.Content, span.separator); + state.lastAppendedTokenIsSeparator = true; + } - // We will assign capturedTokenRange.startIndex to be the index after the last token - // that was appended so far. However, if the last appended token was a separator, omit - // it from the range. - let excerptEndIndex: number = excerptTokens.length; - if (state.lastAppendedTokenIsSeparator) { - excerptEndIndex--; - } + // Are we building a excerpt? If so, set its range + if (captureTokenRange) { + captureTokenRange.startIndex = excerptStartIndex; - captureTokenRange.endIndex = excerptEndIndex; + // We will assign capturedTokenRange.startIndex to be the index after the last token + // that was appended so far. However, if the last appended token was a separator, omit + // it from the range. + let excerptEndIndex: number = excerptTokens.length; + if (state.lastAppendedTokenIsSeparator) { + excerptEndIndex--; } - return true; + captureTokenRange.endIndex = excerptEndIndex; } - private static _appendToken( - excerptTokens: IExcerptToken[], - excerptTokenKind: ExcerptTokenKind, - text: string, - canonicalReference?: DeclarationReference - ): void { - if (text.length === 0) { - return; - } + return true; +} - const excerptToken: IExcerptToken = { kind: excerptTokenKind, text: text }; - if (canonicalReference !== undefined) { - excerptToken.canonicalReference = canonicalReference.toString(); - } - excerptTokens.push(excerptToken); +function _appendToken( + excerptTokens: IExcerptToken[], + excerptTokenKind: ExcerptTokenKind, + text: string, + canonicalReference?: DeclarationReference +): void { + if (text.length === 0) { + return; } - /** - * Condenses the provided excerpt tokens by merging tokens where possible. Updates the provided token ranges to - * remain accurate after token merging. - * - * @remarks - * For example, suppose we have excerpt tokens ["A", "B", "C"] and a token range [0, 2]. If the excerpt tokens - * are condensed to ["AB", "C"], then the token range would be updated to [0, 1]. Note that merges are only - * performed if they are compatible with the provided token ranges. In the example above, if our token range was - * originally [0, 1], we would not be able to merge tokens "A" and "B". - */ - private static _condenseTokens(excerptTokens: IExcerptToken[], tokenRanges: IExcerptTokenRange[]): void { - // This set is used to quickly lookup a start or end index. - const startOrEndIndices: Set = new Set(); - for (const tokenRange of tokenRanges) { - startOrEndIndices.add(tokenRange.startIndex); - startOrEndIndices.add(tokenRange.endIndex); - } + const excerptToken: IExcerptToken = { kind: excerptTokenKind, text: text }; + if (canonicalReference !== undefined) { + excerptToken.canonicalReference = canonicalReference.toString(); + } + excerptTokens.push(excerptToken); +} - for (let currentIndex: number = 1; currentIndex < excerptTokens.length; ++currentIndex) { - while (currentIndex < excerptTokens.length) { - const prevPrevToken: IExcerptToken = excerptTokens[currentIndex - 2]; // May be undefined - const prevToken: IExcerptToken = excerptTokens[currentIndex - 1]; - const currentToken: IExcerptToken = excerptTokens[currentIndex]; - - // The number of excerpt tokens that are merged in this iteration. We need this to determine - // how to update the start and end indices of our token ranges. - let mergeCount: number; - - // There are two types of merges that can occur. We only perform these merges if they are - // compatible with all of our token ranges. - if ( - prevPrevToken && - prevPrevToken.kind === ExcerptTokenKind.Reference && - prevToken.kind === ExcerptTokenKind.Content && - prevToken.text.trim() === '.' && - currentToken.kind === ExcerptTokenKind.Reference && - !startOrEndIndices.has(currentIndex) && - !startOrEndIndices.has(currentIndex - 1) - ) { - // If the current token is a reference token, the previous token is a ".", and the previous- - // previous token is a reference token, then merge all three tokens into a reference token. - // - // For example: Given ["MyNamespace" (R), ".", "MyClass" (R)], tokens "." and "MyClass" might - // be merged into "MyNamespace". The condensed token would be ["MyNamespace.MyClass" (R)]. - prevPrevToken.text += prevToken.text + currentToken.text; - prevPrevToken.canonicalReference = currentToken.canonicalReference; - mergeCount = 2; - currentIndex--; - } else if ( - // If the current and previous tokens are both content tokens, then merge the tokens into a - // single content token. For example: Given ["export ", "declare class"], these tokens - // might be merged into "export declare class". - prevToken.kind === ExcerptTokenKind.Content && - prevToken.kind === currentToken.kind && - !startOrEndIndices.has(currentIndex) - ) { - prevToken.text += currentToken.text; - mergeCount = 1; - } else { - // Otherwise, no merging can occur here. Continue to the next index. - break; - } +/** + * Condenses the provided excerpt tokens by merging tokens where possible. Updates the provided token ranges to + * remain accurate after token merging. + * + * @remarks + * For example, suppose we have excerpt tokens ["A", "B", "C"] and a token range [0, 2]. If the excerpt tokens + * are condensed to ["AB", "C"], then the token range would be updated to [0, 1]. Note that merges are only + * performed if they are compatible with the provided token ranges. In the example above, if our token range was + * originally [0, 1], we would not be able to merge tokens "A" and "B". + */ +function _condenseTokens(excerptTokens: IExcerptToken[], tokenRanges: IExcerptTokenRange[]): void { + // This set is used to quickly lookup a start or end index. + const startOrEndIndices: Set = new Set(); + for (const tokenRange of tokenRanges) { + startOrEndIndices.add(tokenRange.startIndex); + startOrEndIndices.add(tokenRange.endIndex); + } - // Remove the now redundant excerpt token(s), as they were merged into a previous token. - excerptTokens.splice(currentIndex, mergeCount); + for (let currentIndex: number = 1; currentIndex < excerptTokens.length; ++currentIndex) { + while (currentIndex < excerptTokens.length) { + const prevPrevToken: IExcerptToken = excerptTokens[currentIndex - 2]; // May be undefined + const prevToken: IExcerptToken = excerptTokens[currentIndex - 1]; + const currentToken: IExcerptToken = excerptTokens[currentIndex]; + + // The number of excerpt tokens that are merged in this iteration. We need this to determine + // how to update the start and end indices of our token ranges. + let mergeCount: number; + + // There are two types of merges that can occur. We only perform these merges if they are + // compatible with all of our token ranges. + if ( + prevPrevToken && + prevPrevToken.kind === ExcerptTokenKind.Reference && + prevToken.kind === ExcerptTokenKind.Content && + prevToken.text.trim() === '.' && + currentToken.kind === ExcerptTokenKind.Reference && + !startOrEndIndices.has(currentIndex) && + !startOrEndIndices.has(currentIndex - 1) + ) { + // If the current token is a reference token, the previous token is a ".", and the previous- + // previous token is a reference token, then merge all three tokens into a reference token. + // + // For example: Given ["MyNamespace" (R), ".", "MyClass" (R)], tokens "." and "MyClass" might + // be merged into "MyNamespace". The condensed token would be ["MyNamespace.MyClass" (R)]. + prevPrevToken.text += prevToken.text + currentToken.text; + prevPrevToken.canonicalReference = currentToken.canonicalReference; + mergeCount = 2; + currentIndex--; + } else if ( + // If the current and previous tokens are both content tokens, then merge the tokens into a + // single content token. For example: Given ["export ", "declare class"], these tokens + // might be merged into "export declare class". + prevToken.kind === ExcerptTokenKind.Content && + prevToken.kind === currentToken.kind && + !startOrEndIndices.has(currentIndex) + ) { + prevToken.text += currentToken.text; + mergeCount = 1; + } else { + // Otherwise, no merging can occur here. Continue to the next index. + break; + } - // Update the start and end indices for all token ranges based upon how many excerpt - // tokens were merged and in what positions. - for (const tokenRange of tokenRanges) { - if (tokenRange.startIndex > currentIndex) { - tokenRange.startIndex -= mergeCount; - } + // Remove the now redundant excerpt token(s), as they were merged into a previous token. + excerptTokens.splice(currentIndex, mergeCount); - if (tokenRange.endIndex > currentIndex) { - tokenRange.endIndex -= mergeCount; - } + // Update the start and end indices for all token ranges based upon how many excerpt + // tokens were merged and in what positions. + for (const tokenRange of tokenRanges) { + if (tokenRange.startIndex > currentIndex) { + tokenRange.startIndex -= mergeCount; } - // Clear and repopulate our set with the updated indices. - startOrEndIndices.clear(); - for (const tokenRange of tokenRanges) { - startOrEndIndices.add(tokenRange.startIndex); - startOrEndIndices.add(tokenRange.endIndex); + if (tokenRange.endIndex > currentIndex) { + tokenRange.endIndex -= mergeCount; } } + + // Clear and repopulate our set with the updated indices. + startOrEndIndices.clear(); + for (const tokenRange of tokenRanges) { + startOrEndIndices.add(tokenRange.startIndex); + startOrEndIndices.add(tokenRange.endIndex); + } } } +} - private static _isDeclarationName(name: ts.Identifier): boolean { - return ExcerptBuilder._isDeclaration(name.parent) && name.parent.name === name; - } +function _isDeclarationName(name: ts.Identifier): boolean { + return _isDeclaration(name.parent) && name.parent.name === name; +} - private static _isDeclaration(node: ts.Node): node is ts.NamedDeclaration { - switch (node.kind) { - case ts.SyntaxKind.FunctionDeclaration: - case ts.SyntaxKind.FunctionExpression: - case ts.SyntaxKind.VariableDeclaration: - case ts.SyntaxKind.Parameter: - case ts.SyntaxKind.EnumDeclaration: - case ts.SyntaxKind.ClassDeclaration: - case ts.SyntaxKind.ClassExpression: - case ts.SyntaxKind.ModuleDeclaration: - case ts.SyntaxKind.MethodDeclaration: - case ts.SyntaxKind.MethodSignature: - case ts.SyntaxKind.PropertyDeclaration: - case ts.SyntaxKind.PropertySignature: - case ts.SyntaxKind.GetAccessor: - case ts.SyntaxKind.SetAccessor: - case ts.SyntaxKind.InterfaceDeclaration: - case ts.SyntaxKind.TypeAliasDeclaration: - case ts.SyntaxKind.TypeParameter: - case ts.SyntaxKind.EnumMember: - case ts.SyntaxKind.BindingElement: - return true; - default: - return false; - } +function _isDeclaration(node: ts.Node): node is ts.NamedDeclaration { + switch (node.kind) { + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.FunctionExpression: + case ts.SyntaxKind.VariableDeclaration: + case ts.SyntaxKind.Parameter: + case ts.SyntaxKind.EnumDeclaration: + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.ClassExpression: + case ts.SyntaxKind.ModuleDeclaration: + case ts.SyntaxKind.MethodDeclaration: + case ts.SyntaxKind.MethodSignature: + case ts.SyntaxKind.PropertyDeclaration: + case ts.SyntaxKind.PropertySignature: + case ts.SyntaxKind.GetAccessor: + case ts.SyntaxKind.SetAccessor: + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + case ts.SyntaxKind.TypeParameter: + case ts.SyntaxKind.EnumMember: + case ts.SyntaxKind.BindingElement: + return true; + default: + return false; } } diff --git a/apps/heft/src/configuration/HeftPluginConfiguration.ts b/apps/heft/src/configuration/HeftPluginConfiguration.ts index 4bda7fd937a..1842c68d778 100644 --- a/apps/heft/src/configuration/HeftPluginConfiguration.ts +++ b/apps/heft/src/configuration/HeftPluginConfiguration.ts @@ -20,13 +20,13 @@ export interface IHeftPluginConfigurationJson { const HEFT_PLUGIN_CONFIGURATION_FILENAME: 'heft-plugin.json' = 'heft-plugin.json'; +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(heftPluginSchema); +const _pluginConfigurationPromises: Map> = new Map(); + /** * Loads and validates the heft-plugin.json file. */ export class HeftPluginConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(heftPluginSchema); - private static _pluginConfigurationPromises: Map> = new Map(); - private readonly _heftPluginConfigurationJson: IHeftPluginConfigurationJson; private _lifecyclePluginDefinitions: Set | undefined; private _lifecyclePluginDefinitionsMap: Map | undefined; @@ -63,16 +63,16 @@ export class HeftPluginConfiguration { ): Promise { const resolvedHeftPluginConfigurationJsonFilename: string = `${packageRoot}/${HEFT_PLUGIN_CONFIGURATION_FILENAME}`; let heftPluginConfigurationPromise: Promise | undefined = - HeftPluginConfiguration._pluginConfigurationPromises.get(packageRoot); + _pluginConfigurationPromises.get(packageRoot); if (!heftPluginConfigurationPromise) { heftPluginConfigurationPromise = (async () => { const heftPluginConfigurationJson: IHeftPluginConfigurationJson = await JsonFile.loadAndValidateAsync( resolvedHeftPluginConfigurationJsonFilename, - HeftPluginConfiguration._jsonSchema + _jsonSchema ); return new HeftPluginConfiguration(heftPluginConfigurationJson, packageRoot, packageName); })(); - HeftPluginConfiguration._pluginConfigurationPromises.set(packageRoot, heftPluginConfigurationPromise); + _pluginConfigurationPromises.set(packageRoot, heftPluginConfigurationPromise); } return await heftPluginConfigurationPromise; diff --git a/apps/heft/src/plugins/NodeServicePlugin.ts b/apps/heft/src/plugins/NodeServicePlugin.ts index dfb8e42760f..40b2753f55f 100644 --- a/apps/heft/src/plugins/NodeServicePlugin.ts +++ b/apps/heft/src/plugins/NodeServicePlugin.ts @@ -18,6 +18,8 @@ import { CoreConfigFiles } from '../utilities/CoreConfigFiles'; const PLUGIN_NAME: 'node-service-plugin' = 'node-service-plugin'; const SERVE_PARAMETER_LONG_NAME: '--serve' = '--serve'; +const _isWindows: boolean = process.platform === 'win32'; + export interface INodeServicePluginCompleteConfiguration { commandName: string; ignoreMissingScript: boolean; @@ -56,8 +58,6 @@ enum State { } export default class NodeServicePlugin implements IHeftTaskPlugin { - private static readonly _isWindows: boolean = process.platform === 'win32'; - private _activeChildProcess: child_process.ChildProcess | undefined; private _childProcessExitPromise: Promise | undefined; private _childProcessExitPromiseResolveFn: (() => void) | undefined; @@ -208,7 +208,7 @@ export default class NodeServicePlugin implements IHeftTaskPlugin { return; } - if (NodeServicePlugin._isWindows) { + if (_isWindows) { // On Windows, SIGTERM can kill Cmd.exe and leave its children running in the background this._transitionToKilling(); } else { diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index 3ad45c6fda9..ff221359a7d 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -59,12 +59,10 @@ export interface IHeftConfigurationJson { phasesByName?: IHeftConfigurationJsonPhases; } -export class CoreConfigFiles { - private static _heftConfigFileLoader: ProjectConfigurationFile | undefined; - private static _nodeServiceConfigurationLoader: - | ProjectConfigurationFile - | undefined; +let _heftConfigFileLoader: ProjectConfigurationFile | undefined; +let _nodeServiceConfigurationLoader: ProjectConfigurationFile | undefined; +export class CoreConfigFiles { public static heftConfigurationProjectRelativeFilePath: string = `${Constants.projectConfigFolderName}/${Constants.heftConfigurationFilename}`; public static nodeServiceConfigurationProjectRelativeFilePath: string = `${Constants.projectConfigFolderName}/${Constants.nodeServiceConfigurationFilename}`; @@ -77,7 +75,7 @@ export class CoreConfigFiles { projectPath: string, rigConfig?: IRigConfig | undefined ): Promise { - if (!CoreConfigFiles._heftConfigFileLoader) { + if (!_heftConfigFileLoader) { let heftPluginPackageFolder: string | undefined; const pluginPackageResolver: ( @@ -111,7 +109,8 @@ export class CoreConfigFiles { }; const schemaObject: object = await import('../schemas/heft.schema.json'); - CoreConfigFiles._heftConfigFileLoader = new ProjectConfigurationFile({ + // eslint-disable-next-line require-atomic-updates + _heftConfigFileLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, jsonSchemaObject: schemaObject, propertyInheritanceDefaults: { @@ -135,8 +134,7 @@ export class CoreConfigFiles { }); } - const heftConfigFileLoader: ProjectConfigurationFile = - CoreConfigFiles._heftConfigFileLoader; + const heftConfigFileLoader: ProjectConfigurationFile = _heftConfigFileLoader; let configurationFile: IHeftConfigurationJson; try { @@ -231,17 +229,17 @@ export class CoreConfigFiles { projectPath: string, rigConfig?: IRigConfig | undefined ): Promise { - if (!CoreConfigFiles._nodeServiceConfigurationLoader) { + if (!_nodeServiceConfigurationLoader) { const schemaObject: object = await import('../schemas/node-service.schema.json'); - CoreConfigFiles._nodeServiceConfigurationLoader = - new ProjectConfigurationFile({ - projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, - jsonSchemaObject: schemaObject - }); + // eslint-disable-next-line require-atomic-updates + _nodeServiceConfigurationLoader = new ProjectConfigurationFile({ + projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, + jsonSchemaObject: schemaObject + }); } const configurationFile: INodeServicePluginConfiguration | undefined = - await CoreConfigFiles._nodeServiceConfigurationLoader.tryLoadConfigurationFileForProjectAsync( + await _nodeServiceConfigurationLoader.tryLoadConfigurationFileForProjectAsync( terminal, projectPath, rigConfig diff --git a/apps/playwright-browser-tunnel/src/LaunchOptionsValidator.ts b/apps/playwright-browser-tunnel/src/LaunchOptionsValidator.ts index 10f6ee2cf43..dc76ee0a051 100644 --- a/apps/playwright-browser-tunnel/src/LaunchOptionsValidator.ts +++ b/apps/playwright-browser-tunnel/src/LaunchOptionsValidator.ts @@ -59,14 +59,14 @@ export interface ILaunchOptionsValidationResult { warnings: string[]; } +const _allowlistVersion: number = 1; + /** * Validates Playwright launch options against security allowlists. * Provides utilities for managing client-side allowlist configuration. * @beta */ export class LaunchOptionsValidator { - private static readonly _allowlistVersion: number = 1; - /** * Gets the path to the allowlist file in the user's local preferences folder. * This follows the pattern of playwright-browser-installed.txt but stores in user's home directory. @@ -89,7 +89,7 @@ export class LaunchOptionsValidator { if (!FileSystem.exists(allowlistPath)) { return { allowedOptions: [], - version: this._allowlistVersion + version: _allowlistVersion }; } @@ -110,13 +110,13 @@ export class LaunchOptionsValidator { // Invalid format, return empty allowlist return { allowedOptions: [], - version: this._allowlistVersion + version: _allowlistVersion }; } catch (error) { // If we can't read the file, return empty allowlist return { allowedOptions: [], - version: this._allowlistVersion + version: _allowlistVersion }; } } @@ -217,7 +217,7 @@ export class LaunchOptionsValidator { public static async clearAllowlistAsync(): Promise { await this.writeAllowlistAsync({ allowedOptions: [], - version: this._allowlistVersion + version: _allowlistVersion }); } diff --git a/apps/rundown/src/launcher.ts b/apps/rundown/src/launcher.ts index 0804e456618..63738d1bea3 100644 --- a/apps/rundown/src/launcher.ts +++ b/apps/rundown/src/launcher.ts @@ -31,13 +31,6 @@ class Launcher { return [nodeArg, this.targetScriptPathArg, ...remainderArgs]; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static _copyProperties(dst: any, src: any): void { - for (const prop of Object.keys(src)) { - dst[prop] = src[prop]; - } - } - private _sendIpcTraceBatch(): void { if (this._ipcTraceRecordsBatch.length > 0) { const batch: IIpcTraceRecord[] = [...this._ipcTraceRecordsBatch]; @@ -104,7 +97,7 @@ class Launcher { } moduleApi.Module.prototype.require = hookedRequire as NodeJS.Require; - Launcher._copyProperties(hookedRequire, realRequire); + _copyProperties(hookedRequire, realRequire); process.on('exit', () => { this._sendIpcTraceBatch(); @@ -115,6 +108,13 @@ class Launcher { } } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function _copyProperties(dst: any, src: any): void { + for (const prop of Object.keys(src)) { + dst[prop] = src[prop]; + } +} + if (!process.send) { throw new Error('launcher.js must be invoked via IPC'); } diff --git a/apps/rush-mcp-server/src/pluginFramework/RushMcpPluginLoader.ts b/apps/rush-mcp-server/src/pluginFramework/RushMcpPluginLoader.ts index 33603216342..a760a847b0d 100644 --- a/apps/rush-mcp-server/src/pluginFramework/RushMcpPluginLoader.ts +++ b/apps/rush-mcp-server/src/pluginFramework/RushMcpPluginLoader.ts @@ -67,12 +67,10 @@ export interface IJsonRushMcpPluginManifest { entryPoint: string; } -export class RushMcpPluginLoader { - private static readonly _rushMcpJsonSchema: JsonSchema = - JsonSchema.fromLoadedObject(rushMcpJsonSchemaObject); - private static readonly _rushMcpPluginSchemaObject: JsonSchema = - JsonSchema.fromLoadedObject(rushMcpPluginSchemaObject); +const _rushMcpJsonSchema: JsonSchema = JsonSchema.fromLoadedObject(rushMcpJsonSchemaObject); +const _rushMcpPluginSchemaObject: JsonSchema = JsonSchema.fromLoadedObject(rushMcpPluginSchemaObject); +export class RushMcpPluginLoader { private readonly _rushWorkspacePath: string; private readonly _mcpServer: McpServer; @@ -81,10 +79,6 @@ export class RushMcpPluginLoader { this._mcpServer = mcpServer; } - private static _formatError(e: Error): string { - return e.stack ?? RushMcpPluginLoader._formatError(e); - } - public async loadAsync(): Promise { const rushMcpFilePath: string = path.join( this._rushWorkspacePath, @@ -101,7 +95,7 @@ export class RushMcpPluginLoader { const jsonRushMcpConfig: IJsonRushMcpConfig = await JsonFile.loadAndValidateAsync( rushMcpFilePath, - RushMcpPluginLoader._rushMcpJsonSchema + _rushMcpJsonSchema ); if (jsonRushMcpConfig.mcpPlugins.length === 0) { @@ -139,7 +133,7 @@ export class RushMcpPluginLoader { const jsonManifest: IJsonRushMcpPluginManifest = await JsonFile.loadAndValidateAsync( manifestFilePath, - RushMcpPluginLoader._rushMcpPluginSchemaObject + _rushMcpPluginSchemaObject ); let rushMcpPluginOptions: JsonObject = {}; @@ -169,10 +163,7 @@ export class RushMcpPluginLoader { } pluginFactory = entryPointModule.default; } catch (e) { - throw new Error( - `Unable to load plugin entry point at ${fullEntryPointPath}:\n` + - RushMcpPluginLoader._formatError(e) - ); + throw new Error(`Unable to load plugin entry point at ${fullEntryPointPath}:\n` + _formatError(e)); } const session: RushMcpPluginSessionInternal = new RushMcpPluginSessionInternal(this._mcpServer); @@ -182,8 +173,7 @@ export class RushMcpPluginLoader { plugin = pluginFactory(session, rushMcpPluginOptions); } catch (e) { throw new Error( - `Error invoking entry point for plugin ${jsonManifest.pluginName}:\n` + - RushMcpPluginLoader._formatError(e) + `Error invoking entry point for plugin ${jsonManifest.pluginName}:\n` + _formatError(e) ); } @@ -191,10 +181,13 @@ export class RushMcpPluginLoader { await plugin.onInitializeAsync(); } catch (e) { throw new Error( - `Error occurred in onInitializeAsync() for plugin ${jsonManifest.pluginName}:\n` + - RushMcpPluginLoader._formatError(e) + `Error occurred in onInitializeAsync() for plugin ${jsonManifest.pluginName}:\n` + _formatError(e) ); } } } } + +function _formatError(e: Error): string { + return e.stack ?? _formatError(e); +} diff --git a/apps/rush-mcp-server/src/utilities/command-runner.ts b/apps/rush-mcp-server/src/utilities/command-runner.ts index d94424757cb..d553ed3935b 100644 --- a/apps/rush-mcp-server/src/utilities/command-runner.ts +++ b/apps/rush-mcp-server/src/utilities/command-runner.ts @@ -20,88 +20,89 @@ export class CommandExecutionError extends Error { } } -export class CommandRunner { - private static readonly _commandCache: Map = new Map(); - - private static _resolveCommand(command: string): string { - const cachedPath: string | null | undefined = this._commandCache.get(command); - if (cachedPath === null) { - throw new Error(`Command "${command}" not found in system PATH`); - } - - if (cachedPath) { - return cachedPath; - } - - const resolvedPath: string | null = Executable.tryResolve(command) ?? null; - this._commandCache.set(command, resolvedPath); - - if (!resolvedPath) { - throw new Error(`Command "${command}" not found in system PATH`); - } - - return resolvedPath; - } - - private static async _executeCommandAsync( - command: string, - args: string[], - options?: IExecutableSpawnSyncOptions - ): Promise { - const commandPath: string = this._resolveCommand(command); - - return new Promise((resolve, reject) => { - const childProcess: ChildProcess = Executable.spawn(commandPath, args, options); - let stdout: string = ''; - let stderr: string = ''; - - childProcess.stdout?.on('data', (data) => { - stdout += data.toString(); - }); - - childProcess.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - childProcess.on('close', (status) => { - if (status !== 0) { - reject(new CommandExecutionError(command, args, stderr, status ?? 1)); - return; - } - - resolve({ - status: status ?? 0, - stdout, - stderr, - command, - args - }); - }); - - childProcess.on('error', (error) => { - reject(error); - }); - }); - } +// eslint-disable-next-line @rushstack/no-new-null +const _commandCache: Map = new Map(); +export class CommandRunner { public static async runRushCommandAsync( args: string[], options?: IExecutableSpawnSyncOptions ): Promise { - return this._executeCommandAsync('rush', args, options); + return _executeCommandAsync('rush', args, options); } public static async runRushXCommandAsync( args: string[], options?: IExecutableSpawnSyncOptions ): Promise { - return this._executeCommandAsync('rushx', args, options); + return _executeCommandAsync('rushx', args, options); } public static async runGitCommandAsync( args: string[], options?: IExecutableSpawnSyncOptions ): Promise { - return this._executeCommandAsync('git', args, options); + return _executeCommandAsync('git', args, options); + } +} + +function _resolveCommand(command: string): string { + const cachedPath: string | null | undefined = _commandCache.get(command); + if (cachedPath === null) { + throw new Error(`Command "${command}" not found in system PATH`); + } + + if (cachedPath) { + return cachedPath; } + + const resolvedPath: string | null = Executable.tryResolve(command) ?? null; + _commandCache.set(command, resolvedPath); + + if (!resolvedPath) { + throw new Error(`Command "${command}" not found in system PATH`); + } + + return resolvedPath; +} + +async function _executeCommandAsync( + command: string, + args: string[], + options?: IExecutableSpawnSyncOptions +): Promise { + const commandPath: string = _resolveCommand(command); + + return new Promise((resolve, reject) => { + const childProcess: ChildProcess = Executable.spawn(commandPath, args, options); + let stdout: string = ''; + let stderr: string = ''; + + childProcess.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + childProcess.on('close', (status) => { + if (status !== 0) { + reject(new CommandExecutionError(command, args, stderr, status ?? 1)); + return; + } + + resolve({ + status: status ?? 0, + stdout, + stderr, + command, + args + }); + }); + + childProcess.on('error', (error) => { + reject(error); + }); + }); } diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 902aea3224a..0cc4436b964 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -37,17 +37,13 @@ export class MinimalRushConfiguration { showVerbose: !RushCommandLineParser.shouldRestrictConsoleOutput() }); if (rushJsonLocation) { - return MinimalRushConfiguration._loadFromConfigurationFile(rushJsonLocation); - } else { + const minimalRushConfigurationJson: IMinimalRushConfigurationJson | undefined = + _loadConfigurationJson(rushJsonLocation); + if (minimalRushConfigurationJson) { + return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + } return undefined; - } - } - - private static _loadFromConfigurationFile(rushJsonFilename: string): MinimalRushConfiguration | undefined { - try { - const minimalRushConfigurationJson: IMinimalRushConfigurationJson = JsonFile.load(rushJsonFilename); - return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonFilename); - } catch (e) { + } else { return undefined; } } @@ -73,3 +69,11 @@ export class MinimalRushConfiguration { return this._commonRushConfigFolder; } } + +function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { + try { + return JsonFile.load(rushJsonFilename); + } catch (e) { + return undefined; + } +} diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 3b30557bd97..d85f00c5a91 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -17,9 +17,9 @@ type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined; */ export class RushCommandSelector { public static failIfNotInvokedAsRush(version: string): void { - const commandName: CommandName = RushCommandSelector._getCommandName(); + const commandName: CommandName = _getCommandName(); if (commandName !== 'rush' && commandName !== undefined) { - RushCommandSelector._failWithError( + _failWithError( `This repository is using Rush version ${version} which does not support the ${commandName} command` ); } @@ -34,14 +34,14 @@ export class RushCommandSelector { if (!Rush) { // This should be impossible unless we somehow loaded an unexpected version - RushCommandSelector._failWithError(`Unable to find the "Rush" entry point in @microsoft/rush-lib`); + _failWithError(`Unable to find the "Rush" entry point in @microsoft/rush-lib`); } - const commandName: CommandName = RushCommandSelector._getCommandName(); + const commandName: CommandName = _getCommandName(); if (commandName === 'rush-pnpm') { if (!Rush.launchRushPnpm) { - RushCommandSelector._failWithError( + _failWithError( `This repository is using Rush version ${Rush.version}` + ` which does not support the "rush-pnpm" command` ); @@ -52,7 +52,7 @@ export class RushCommandSelector { }); } else if (commandName === 'rushx') { if (!Rush.launchRushX) { - RushCommandSelector._failWithError( + _failWithError( `This repository is using Rush version ${Rush.version}` + ` which does not support the "rushx" command` ); @@ -62,28 +62,28 @@ export class RushCommandSelector { Rush.launch(launcherVersion, options); } } +} - private static _failWithError(message: string): never { - console.log(Colorize.red(message)); - return process.exit(1); - } +function _failWithError(message: string): never { + console.log(Colorize.red(message)); + return process.exit(1); +} - private static _getCommandName(): CommandName { - if (process.argv.length >= 2) { - // Example: - // argv[0]: "C:\\Program Files\\nodejs\\node.exe" - // argv[1]: "C:\\Program Files\\nodejs\\node_modules\\@microsoft\\rush\\bin\\rushx" - const basename: string = path.basename(process.argv[1]).toUpperCase(); - if (basename === 'RUSH') { - return 'rush'; - } - if (basename === 'RUSH-PNPM') { - return 'rush-pnpm'; - } - if (basename === 'RUSHX') { - return 'rushx'; - } +function _getCommandName(): CommandName { + if (process.argv.length >= 2) { + // Example: + // argv[0]: "C:\\Program Files\\nodejs\\node.exe" + // argv[1]: "C:\\Program Files\\nodejs\\node_modules\\@microsoft\\rush\\bin\\rushx" + const basename: string = path.basename(process.argv[1]).toUpperCase(); + if (basename === 'RUSH') { + return 'rush'; + } + if (basename === 'RUSH-PNPM') { + return 'rush-pnpm'; + } + if (basename === 'RUSHX') { + return 'rushx'; } - return undefined; } + return undefined; } diff --git a/eslint/eslint-plugin-packlets/src/DependencyAnalyzer.ts b/eslint/eslint-plugin-packlets/src/DependencyAnalyzer.ts index 02f7d33b210..71ad4b8609b 100644 --- a/eslint/eslint-plugin-packlets/src/DependencyAnalyzer.ts +++ b/eslint/eslint-plugin-packlets/src/DependencyAnalyzer.ts @@ -89,95 +89,79 @@ interface IImportListNode extends IPackletImport { previousNode: IImportListNode | undefined; } -export class DependencyAnalyzer { - /** - * @param packletName - the packlet to be checked next in our traversal - * @param startingPackletName - the packlet that we started with; if the traversal reaches this packlet, - * then a circular dependency has been detected - * @param refFileMap - the compiler's `refFileMap` data structure describing import relationships - * @param fileIncludeReasonsMap - the compiler's data structure describing import relationships - * @param program - the compiler's `ts.Program` object - * @param packletsFolderPath - the absolute path of the "src/packlets" folder. - * @param visitedPacklets - the set of packlets that have already been visited in this traversal - * @param previousNode - a linked list of import statements that brought us to this step in the traversal - */ - private static _walkImports( - packletName: string, - startingPackletName: string, - refFileMap: Map | undefined, - fileIncludeReasonsMap: Map | undefined, - program: ts.Program, - packletsFolderPath: string, - visitedPacklets: Set, - previousNode: IImportListNode | undefined - ): IImportListNode | undefined { - visitedPacklets.add(packletName); +/** + * @param packletName - the packlet to be checked next in our traversal + * @param startingPackletName - the packlet that we started with; if the traversal reaches this packlet, + * then a circular dependency has been detected + * @param refFileMap - the compiler's `refFileMap` data structure describing import relationships + * @param fileIncludeReasonsMap - the compiler's data structure describing import relationships + * @param program - the compiler's `ts.Program` object + * @param packletsFolderPath - the absolute path of the "src/packlets" folder. + * @param visitedPacklets - the set of packlets that have already been visited in this traversal + * @param previousNode - a linked list of import statements that brought us to this step in the traversal + */ +function _walkImports( + packletName: string, + startingPackletName: string, + refFileMap: Map | undefined, + fileIncludeReasonsMap: Map | undefined, + program: ts.Program, + packletsFolderPath: string, + visitedPacklets: Set, + previousNode: IImportListNode | undefined +): IImportListNode | undefined { + visitedPacklets.add(packletName); - const packletEntryPoint: string = Path.join(packletsFolderPath, packletName, 'index'); + const packletEntryPoint: string = Path.join(packletsFolderPath, packletName, 'index'); - const tsSourceFile: ts.SourceFile | undefined = - program.getSourceFile(packletEntryPoint + '.ts') || program.getSourceFile(packletEntryPoint + '.tsx'); - if (!tsSourceFile) { - return undefined; - } + const tsSourceFile: ts.SourceFile | undefined = + program.getSourceFile(packletEntryPoint + '.ts') || program.getSourceFile(packletEntryPoint + '.tsx'); + if (!tsSourceFile) { + return undefined; + } - const referencingFilePaths: string[] = []; + const referencingFilePaths: string[] = []; - if (refFileMap) { - // TypeScript version range: >= 3.6.0, <= 4.2.0 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const refFiles: IRefFile[] | undefined = refFileMap.get((tsSourceFile as any).path); - if (refFiles) { - for (const refFile of refFiles) { - if (refFile.kind === RefFileKind.Import) { - referencingFilePaths.push(refFile.file); - } + if (refFileMap) { + // TypeScript version range: >= 3.6.0, <= 4.2.0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const refFiles: IRefFile[] | undefined = refFileMap.get((tsSourceFile as any).path); + if (refFiles) { + for (const refFile of refFiles) { + if (refFile.kind === RefFileKind.Import) { + referencingFilePaths.push(refFile.file); } } - } else if (fileIncludeReasonsMap) { - // Typescript version range: > 4.2.0 - const fileIncludeReasons: IFileIncludeReason[] | undefined = fileIncludeReasonsMap.get( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (tsSourceFile as any).path - ); - if (fileIncludeReasons) { - for (const fileIncludeReason of fileIncludeReasons) { - if (fileIncludeReason.kind === FileIncludeKind.Import) { - if (fileIncludeReason.file) { - referencingFilePaths.push(fileIncludeReason.file); - } + } + } else if (fileIncludeReasonsMap) { + // Typescript version range: > 4.2.0 + const fileIncludeReasons: IFileIncludeReason[] | undefined = fileIncludeReasonsMap.get( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tsSourceFile as any).path + ); + if (fileIncludeReasons) { + for (const fileIncludeReason of fileIncludeReasons) { + if (fileIncludeReason.kind === FileIncludeKind.Import) { + if (fileIncludeReason.file) { + referencingFilePaths.push(fileIncludeReason.file); } } } } + } - for (const referencingFilePath of referencingFilePaths) { - // Is it a reference to a packlet? - if (Path.isUnder(referencingFilePath, packletsFolderPath)) { - const referencingRelativePath: string = Path.relative(packletsFolderPath, referencingFilePath); - const referencingPathParts: string[] = referencingRelativePath.split(/[\/\\]+/); - const referencingPackletName: string = referencingPathParts[0]; - - // Did we return to where we started from? - if (referencingPackletName === startingPackletName) { - // Ignore the degenerate case where the starting node imports itself, - // since @rushstack/packlets/mechanics will already report that. - if (previousNode) { - // Make a new linked list node to record this step of the traversal - const importListNode: IImportListNode = { - previousNode: previousNode, - fromFilePath: referencingFilePath, - packletName: packletName - }; - - // The traversal has returned to the packlet that we started from; - // this means we have detected a circular dependency - return importListNode; - } - } + for (const referencingFilePath of referencingFilePaths) { + // Is it a reference to a packlet? + if (Path.isUnder(referencingFilePath, packletsFolderPath)) { + const referencingRelativePath: string = Path.relative(packletsFolderPath, referencingFilePath); + const referencingPathParts: string[] = referencingRelativePath.split(/[\/\\]+/); + const referencingPackletName: string = referencingPathParts[0]; - // Have we already analyzed this packlet? - if (!visitedPacklets.has(referencingPackletName)) { + // Did we return to where we started from? + if (referencingPackletName === startingPackletName) { + // Ignore the degenerate case where the starting node imports itself, + // since @rushstack/packlets/mechanics will already report that. + if (previousNode) { // Make a new linked list node to record this step of the traversal const importListNode: IImportListNode = { previousNode: previousNode, @@ -185,26 +169,42 @@ export class DependencyAnalyzer { packletName: packletName }; - const result: IImportListNode | undefined = DependencyAnalyzer._walkImports( - referencingPackletName, - startingPackletName, - refFileMap, - fileIncludeReasonsMap, - program, - packletsFolderPath, - visitedPacklets, - importListNode - ); - if (result) { - return result; - } + // The traversal has returned to the packlet that we started from; + // this means we have detected a circular dependency + return importListNode; } } - } - return undefined; + // Have we already analyzed this packlet? + if (!visitedPacklets.has(referencingPackletName)) { + // Make a new linked list node to record this step of the traversal + const importListNode: IImportListNode = { + previousNode: previousNode, + fromFilePath: referencingFilePath, + packletName: packletName + }; + + const result: IImportListNode | undefined = _walkImports( + referencingPackletName, + startingPackletName, + refFileMap, + fileIncludeReasonsMap, + program, + packletsFolderPath, + visitedPacklets, + importListNode + ); + if (result) { + return result; + } + } + } } + return undefined; +} + +export class DependencyAnalyzer { /** * For the specified packlet, trace all modules that import it, looking for a circular dependency * between packlets. If found, an array is returned describing the import statements that cause @@ -257,7 +257,7 @@ export class DependencyAnalyzer { const visitedPacklets: Set = new Set(); - const listNode: IImportListNode | undefined = DependencyAnalyzer._walkImports( + const listNode: IImportListNode | undefined = _walkImports( packletName, packletName, refFileMap, diff --git a/eslint/eslint-plugin-packlets/src/PackletAnalyzer.ts b/eslint/eslint-plugin-packlets/src/PackletAnalyzer.ts index b0149b24519..7c0e98e621d 100644 --- a/eslint/eslint-plugin-packlets/src/PackletAnalyzer.ts +++ b/eslint/eslint-plugin-packlets/src/PackletAnalyzer.ts @@ -23,9 +23,9 @@ export interface IAnalyzerError { data?: Readonly>; } -export class PackletAnalyzer { - private static _validPackletName: RegExp = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const _validPackletName: RegExp = /^[a-z0-9]+(-[a-z0-9]+)*$/; +export class PackletAnalyzer { /** * The input file being linted. * @@ -148,7 +148,7 @@ export class PackletAnalyzer { const thirdPartWithoutExtension: string = Path.parse(thirdPart).name; if (thirdPartWithoutExtension.toUpperCase() === 'INDEX') { - if (!PackletAnalyzer._validPackletName.test(packletName)) { + if (!_validPackletName.test(packletName)) { this.error = { messageId: 'invalid-packlet-name', data: { packletName } }; return; } diff --git a/eslint/eslint-plugin-packlets/src/Path.ts b/eslint/eslint-plugin-packlets/src/Path.ts index b49def2434d..e5a015bcfb6 100644 --- a/eslint/eslint-plugin-packlets/src/Path.ts +++ b/eslint/eslint-plugin-packlets/src/Path.ts @@ -30,78 +30,11 @@ export class Path { * * @see {@link https://nodejs.org/en/docs/guides/working-with-different-filesystems/} */ - public static usingCaseSensitive: boolean = Path._detectCaseSensitive(); - - private static _detectCaseSensitive(): boolean { - // Can our own file be accessed using a path with different case? If so, then the filesystem is case-insensitive. - return !fs.existsSync(__filename.toUpperCase()); - } - - // Removes redundant trailing slashes from a path. - private static _trimTrailingSlashes(inputPath: string): string { - // Examples: - // "/a/b///\\" --> "/a/b" - // "/" --> "/" - return inputPath.replace(/(?<=[^\/\\])[\/\\]+$/, ''); - } - - // An implementation of path.relative() that is case-insensitive. - private static _relativeCaseInsensitive(from: string, to: string): string { - // path.relative() apples path.normalize() and also trims any trailing slashes. - // Since we'll be matching toNormalized against result, we need to do that for our string as well. - const normalizedTo: string = Path._trimTrailingSlashes(path.normalize(to)); - - // We start by converting everything to uppercase and call path.relative() - const uppercasedFrom: string = from.toUpperCase(); - const uppercasedTo: string = normalizedTo.toUpperCase(); - - // The result will be all uppercase because its inputs were uppercased - const uppercasedResult: string = path.relative(uppercasedFrom, uppercasedTo); - - // Are there any cased characters in the result? - if (uppercasedResult.toLowerCase() === uppercasedResult) { - // No cased characters - // Example: "../.." - return uppercasedResult; - } - - // Example: - // from="/a/b/c" - // to="/a/b/d/e" - // - // fromNormalized="/A/B/C" - // toNormalized="/A/B/D/E" - // - // result="../D/E" - // - // Scan backwards comparing uppercasedResult versus uppercasedTo, stopping at the first place where they differ. - let resultIndex: number = uppercasedResult.length; - let toIndex: number = normalizedTo.length; - for (;;) { - if (resultIndex === 0 || toIndex === 0) { - // Stop if we reach the start of the string - break; - } - - if (uppercasedResult.charCodeAt(resultIndex - 1) !== uppercasedTo.charCodeAt(toIndex - 1)) { - // Stop before we reach a character that is different - break; - } - - --resultIndex; - --toIndex; - } - - // Replace the matching part with the properly cased substring from the "normalizedTo" input - // - // Example: - // ".." + "/d/e" = "../d/e" - return uppercasedResult.substring(0, resultIndex) + normalizedTo.substring(toIndex); - } + public static usingCaseSensitive: boolean = _detectCaseSensitive(); public static relative(from: string, to: string): string { if (!Path.usingCaseSensitive) { - return Path._relativeCaseInsensitive(from, to); + return _relativeCaseInsensitive(from, to); } return path.relative(from, to); } @@ -164,3 +97,70 @@ export class Path { return inputPath.split('\\').join('/'); } } + +function _detectCaseSensitive(): boolean { + // Can our own file be accessed using a path with different case? If so, then the filesystem is case-insensitive. + return !fs.existsSync(__filename.toUpperCase()); +} + +// Removes redundant trailing slashes from a path. +function _trimTrailingSlashes(inputPath: string): string { + // Examples: + // "/a/b///\\" --> "/a/b" + // "/" --> "/" + return inputPath.replace(/(?<=[^\/\\])[\/\\]+$/, ''); +} + +// An implementation of path.relative() that is case-insensitive. +export function _relativeCaseInsensitive(from: string, to: string): string { + // path.relative() apples path.normalize() and also trims any trailing slashes. + // Since we'll be matching toNormalized against result, we need to do that for our string as well. + const normalizedTo: string = _trimTrailingSlashes(path.normalize(to)); + + // We start by converting everything to uppercase and call path.relative() + const uppercasedFrom: string = from.toUpperCase(); + const uppercasedTo: string = normalizedTo.toUpperCase(); + + // The result will be all uppercase because its inputs were uppercased + const uppercasedResult: string = path.relative(uppercasedFrom, uppercasedTo); + + // Are there any cased characters in the result? + if (uppercasedResult.toLowerCase() === uppercasedResult) { + // No cased characters + // Example: "../.." + return uppercasedResult; + } + + // Example: + // from="/a/b/c" + // to="/a/b/d/e" + // + // fromNormalized="/A/B/C" + // toNormalized="/A/B/D/E" + // + // result="../D/E" + // + // Scan backwards comparing uppercasedResult versus uppercasedTo, stopping at the first place where they differ. + let resultIndex: number = uppercasedResult.length; + let toIndex: number = normalizedTo.length; + for (;;) { + if (resultIndex === 0 || toIndex === 0) { + // Stop if we reach the start of the string + break; + } + + if (uppercasedResult.charCodeAt(resultIndex - 1) !== uppercasedTo.charCodeAt(toIndex - 1)) { + // Stop before we reach a character that is different + break; + } + + --resultIndex; + --toIndex; + } + + // Replace the matching part with the properly cased substring from the "normalizedTo" input + // + // Example: + // ".." + "/d/e" = "../d/e" + return uppercasedResult.substring(0, resultIndex) + normalizedTo.substring(toIndex); +} diff --git a/eslint/eslint-plugin-packlets/src/test/Path.test.ts b/eslint/eslint-plugin-packlets/src/test/Path.test.ts index 57d75b1be1b..74f74d8ac2f 100644 --- a/eslint/eslint-plugin-packlets/src/test/Path.test.ts +++ b/eslint/eslint-plugin-packlets/src/test/Path.test.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; -import { Path } from '../Path'; +import { Path, _relativeCaseInsensitive } from '../Path'; function toPosixPath(value: string): string { return value.replace(/[\\\/]/g, '/'); @@ -12,7 +12,7 @@ function toNativePath(value: string): string { } function relativeCaseInsensitive(from: string, to: string): string { - return toPosixPath(Path['_relativeCaseInsensitive'](toNativePath(from), toNativePath(to))); + return toPosixPath(_relativeCaseInsensitive(toNativePath(from), toNativePath(to))); } describe(Path.name, () => { diff --git a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts index 66cedc19f9d..6ad53a10a48 100644 --- a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts +++ b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts @@ -132,6 +132,8 @@ const JSONPATHPROPERTY_REGEX: RegExp = /^\$\['([^']+)'\]/; const JEST_CONFIG_PACKAGE_FOLDER: string = path.dirname(require.resolve('jest-config')); +let _jestConfigurationFileLoader: ProjectConfigurationFile | undefined; + interface IPendingTestRun { (): Promise; } @@ -140,8 +142,6 @@ interface IPendingTestRun { * @internal */ export default class JestPlugin implements IHeftTaskPlugin { - private static _jestConfigurationFileLoader: ProjectConfigurationFile | undefined; - private _jestPromise: Promise | undefined; private _pendingTestRuns: Set = new Set(); private _executing: boolean = false; @@ -641,13 +641,12 @@ export default class JestPlugin implements IHeftTaskPlugin { if (!options.debugHeftReporter) { // Extract the reporters and transform to include the Heft reporter by default - (jestArgv as unknown as { reporters: JestReporterConfig[] }).reporters = - JestPlugin._extractHeftJestReporters( - taskSession, - heftConfiguration, - jestConfig, - projectRelativeFilePath - ); + (jestArgv as unknown as { reporters: JestReporterConfig[] }).reporters = _extractHeftJestReporters( + taskSession, + heftConfiguration, + jestConfig, + projectRelativeFilePath + ); } else { logger.emitWarning( new Error('The "--debug-heft-reporter" parameter was specified; disabling HeftJestReporter') @@ -677,7 +676,7 @@ export default class JestPlugin implements IHeftTaskPlugin { buildFolder: string, projectRelativeFilePath: string ): ProjectConfigurationFile { - if (!JestPlugin._jestConfigurationFileLoader) { + if (!_jestConfigurationFileLoader) { // By default, ConfigurationFile will replace all objects, so we need to provide merge functions for these const shallowObjectInheritanceFunc: | undefined>( currentObject: T, @@ -716,17 +715,15 @@ export default class JestPlugin implements IHeftTaskPlugin { }) as T; }; - const tokenResolveMetadata: ICustomJsonPathMetadata = - JestPlugin._getJsonPathMetadata({ - rootDir: buildFolder - }); - const jestResolveMetadata: ICustomJsonPathMetadata = - JestPlugin._getJsonPathMetadata({ - rootDir: buildFolder, - resolveAsModule: true - }); + const tokenResolveMetadata: ICustomJsonPathMetadata = _getJsonPathMetadata({ + rootDir: buildFolder + }); + const jestResolveMetadata: ICustomJsonPathMetadata = _getJsonPathMetadata({ + rootDir: buildFolder, + resolveAsModule: true + }); - JestPlugin._jestConfigurationFileLoader = new ProjectConfigurationFile({ + _jestConfigurationFileLoader = new ProjectConfigurationFile({ projectRelativeFilePath: projectRelativeFilePath, // Bypass Jest configuration validation jsonSchemaObject: anythingSchema, @@ -796,7 +793,7 @@ export default class JestPlugin implements IHeftTaskPlugin { }); } - return JestPlugin._jestConfigurationFileLoader; + return _jestConfigurationFileLoader; } private _setNodeEnvIfRequested(options: IJestPluginOptions, logger: IScopedLogger): void { @@ -821,263 +818,256 @@ export default class JestPlugin implements IHeftTaskPlugin { delete process.env.NODE_ENV; } } +} - private static _extractHeftJestReporters( - taskSession: IHeftTaskSession, - heftConfiguration: HeftConfiguration, - config: IHeftJestConfiguration, - projectRelativeFilePath: string - ): JestReporterConfig[] { - let isUsingHeftReporter: boolean = false; - - const logger: IScopedLogger = taskSession.logger; - const terminal: ITerminal = logger.terminal; - const reporterOptions: IHeftJestReporterOptions = { - heftConfiguration, - logger, - debugMode: taskSession.parameters.debug - }; - if (Array.isArray(config.reporters)) { - // Harvest all the array indices that need to modified before altering the array - const heftReporterIndices: number[] = JestPlugin._findIndexes(config.reporters, 'default'); - - // Replace 'default' reporter with the heft reporter - // This may clobber default reporters options - if (heftReporterIndices.length > 0) { - const heftReporter: Config.ReporterConfig = JestPlugin._getHeftJestReporterConfig(reporterOptions); - for (const index of heftReporterIndices) { - config.reporters[index] = heftReporter; - } - isUsingHeftReporter = true; +function _extractHeftJestReporters( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + config: IHeftJestConfiguration, + projectRelativeFilePath: string +): JestReporterConfig[] { + let isUsingHeftReporter: boolean = false; + + const logger: IScopedLogger = taskSession.logger; + const terminal: ITerminal = logger.terminal; + const reporterOptions: IHeftJestReporterOptions = { + heftConfiguration, + logger, + debugMode: taskSession.parameters.debug + }; + if (Array.isArray(config.reporters)) { + // Harvest all the array indices that need to modified before altering the array + const heftReporterIndices: number[] = _findIndexes(config.reporters, 'default'); + + // Replace 'default' reporter with the heft reporter + // This may clobber default reporters options + if (heftReporterIndices.length > 0) { + const heftReporter: Config.ReporterConfig = _getHeftJestReporterConfig(reporterOptions); + for (const index of heftReporterIndices) { + config.reporters[index] = heftReporter; } - } else if (typeof config.reporters === 'undefined' || config.reporters === null) { - // Otherwise if no reporters are specified install only the heft reporter - config.reporters = [JestPlugin._getHeftJestReporterConfig(reporterOptions)]; isUsingHeftReporter = true; - } else { - // Making a note if Heft cannot understand the reporter entry in Jest config - // Not making this an error or warning because it does not warrant blocking a dev or CI test pass - // If the Jest config is truly wrong Jest itself is in a better position to report what is wrong with the config - terminal.writeVerboseLine( - `The 'reporters' entry in Jest config '${projectRelativeFilePath}' is in an unexpected format. Was ` + - 'expecting an array of reporters' - ); - } - - if (!isUsingHeftReporter) { - terminal.writeVerboseLine( - `HeftJestReporter was not specified in Jest config '${projectRelativeFilePath}'. Consider adding a ` + - "'default' entry in the reporters array." - ); } - - // Since we're injecting the HeftConfiguration, we need to pass these args directly and not through serialization - const reporters: JestReporterConfig[] = config.reporters; - config.reporters = undefined; - return reporters; + } else if (typeof config.reporters === 'undefined' || config.reporters === null) { + // Otherwise if no reporters are specified install only the heft reporter + config.reporters = [_getHeftJestReporterConfig(reporterOptions)]; + isUsingHeftReporter = true; + } else { + // Making a note if Heft cannot understand the reporter entry in Jest config + // Not making this an error or warning because it does not warrant blocking a dev or CI test pass + // If the Jest config is truly wrong Jest itself is in a better position to report what is wrong with the config + terminal.writeVerboseLine( + `The 'reporters' entry in Jest config '${projectRelativeFilePath}' is in an unexpected format. Was ` + + 'expecting an array of reporters' + ); } - /** - * Returns the reporter config using the HeftJestReporter and the provided options. - */ - private static _getHeftJestReporterConfig( - reporterOptions: IHeftJestReporterOptions - ): Config.ReporterConfig { - return [ - `${__dirname}/HeftJestReporter.js`, - reporterOptions as Record - ]; + if (!isUsingHeftReporter) { + terminal.writeVerboseLine( + `HeftJestReporter was not specified in Jest config '${projectRelativeFilePath}'. Consider adding a ` + + "'default' entry in the reporters array." + ); } - /** - * Resolve all specified properties to an absolute path using Jest resolution. In addition, the following - * transforms will be applied to the provided propertyValue before resolution: - * - replace `` with the same rootDir - * - replace `` with the directory containing the current configuration file - * - replace `` with the path to the resolved package (NOT module) - */ - private static _getJsonPathMetadata( - options: IJestResolutionOptions - ): ICustomJsonPathMetadata { - return { - customResolver: (resolverOptions: IJsonPathMetadataResolverOptions) => { - const { propertyName, configurationFilePath, configurationFile } = resolverOptions; - let { propertyValue } = resolverOptions; - - const configDir: string = path.dirname(configurationFilePath); - const parsedPropertyName: string | undefined = propertyName?.match(JSONPATHPROPERTY_REGEX)?.[1]; - - function requireResolveFunction(request: string): string { - return require.resolve(request, { - paths: [configDir, PLUGIN_PACKAGE_FOLDER, JEST_CONFIG_PACKAGE_FOLDER] - }); - } + // Since we're injecting the HeftConfiguration, we need to pass these args directly and not through serialization + const reporters: JestReporterConfig[] = config.reporters; + config.reporters = undefined; + return reporters; +} - // Compare with replaceRootDirInPath() from here: - // https://github.com/facebook/jest/blob/5f4dd187d89070d07617444186684c20d9213031/packages/jest-config/src/utils.ts#L58 - if (propertyValue.startsWith(ROOTDIR_TOKEN)) { - // Example: /path/to/file.js - const restOfPath: string = path.normalize('./' + propertyValue.slice(ROOTDIR_TOKEN.length)); - propertyValue = path.resolve(options.rootDir, restOfPath); - } else if (propertyValue.startsWith(CONFIGDIR_TOKEN)) { - // Example: /path/to/file.js - const restOfPath: string = path.normalize('./' + propertyValue.slice(CONFIGDIR_TOKEN.length)); - propertyValue = path.resolve(configDir, restOfPath); - } else { - // Example: /path/to/file.js - const packageDirMatches: RegExpExecArray | null = PACKAGEDIR_REGEX.exec(propertyValue); - if (packageDirMatches !== null) { - const packageName: string | undefined = packageDirMatches.groups?.[PACKAGE_CAPTUREGROUP]; - if (!packageName) { - throw new Error( - `Could not parse package name from "packageDir" token ` + - (parsedPropertyName ? `of property "${parsedPropertyName}" ` : '') + - `in "${configDir}".` - ); - } +/** + * Returns the reporter config using the HeftJestReporter and the provided options. + */ +function _getHeftJestReporterConfig(reporterOptions: IHeftJestReporterOptions): Config.ReporterConfig { + return [ + `${__dirname}/HeftJestReporter.js`, + reporterOptions as Record + ]; +} - if (!PackageName.isValidName(packageName)) { - throw new Error( - `Module paths are not supported when using the "packageDir" token ` + - (parsedPropertyName ? `of property "${parsedPropertyName}" ` : '') + - `in "${configDir}". Only a package name is allowed.` - ); - } +/** + * Resolve all specified properties to an absolute path using Jest resolution. In addition, the following + * transforms will be applied to the provided propertyValue before resolution: + * - replace `` with the same rootDir + * - replace `` with the directory containing the current configuration file + * - replace `` with the path to the resolved package (NOT module) + */ +function _getJsonPathMetadata( + options: IJestResolutionOptions +): ICustomJsonPathMetadata { + return { + customResolver: (resolverOptions: IJsonPathMetadataResolverOptions) => { + const { propertyName, configurationFilePath, configurationFile } = resolverOptions; + let { propertyValue } = resolverOptions; + + const configDir: string = path.dirname(configurationFilePath); + const parsedPropertyName: string | undefined = propertyName?.match(JSONPATHPROPERTY_REGEX)?.[1]; + + function requireResolveFunction(request: string): string { + return require.resolve(request, { + paths: [configDir, PLUGIN_PACKAGE_FOLDER, JEST_CONFIG_PACKAGE_FOLDER] + }); + } - // Resolve to the package directory (not the module referenced by the package). The normal resolution - // method will generally not be able to find @rushstack/heft-jest-plugin from a project that is - // using a rig. Since it is important, and it is our own package, we resolve it manually as a special - // case. - const resolvedPackagePath: string = - packageName === PLUGIN_PACKAGE_NAME - ? PLUGIN_PACKAGE_FOLDER - : Import.resolvePackage({ baseFolderPath: configDir, packageName, useNodeJSResolver: true }); - // First entry is the entire match - const restOfPath: string = path.normalize( - './' + propertyValue.slice(packageDirMatches[0].length) + // Compare with replaceRootDirInPath() from here: + // https://github.com/facebook/jest/blob/5f4dd187d89070d07617444186684c20d9213031/packages/jest-config/src/utils.ts#L58 + if (propertyValue.startsWith(ROOTDIR_TOKEN)) { + // Example: /path/to/file.js + const restOfPath: string = path.normalize('./' + propertyValue.slice(ROOTDIR_TOKEN.length)); + propertyValue = path.resolve(options.rootDir, restOfPath); + } else if (propertyValue.startsWith(CONFIGDIR_TOKEN)) { + // Example: /path/to/file.js + const restOfPath: string = path.normalize('./' + propertyValue.slice(CONFIGDIR_TOKEN.length)); + propertyValue = path.resolve(configDir, restOfPath); + } else { + // Example: /path/to/file.js + const packageDirMatches: RegExpExecArray | null = PACKAGEDIR_REGEX.exec(propertyValue); + if (packageDirMatches !== null) { + const packageName: string | undefined = packageDirMatches.groups?.[PACKAGE_CAPTUREGROUP]; + if (!packageName) { + throw new Error( + `Could not parse package name from "packageDir" token ` + + (parsedPropertyName ? `of property "${parsedPropertyName}" ` : '') + + `in "${configDir}".` ); - propertyValue = path.resolve(resolvedPackagePath, restOfPath); } - } - // Return early, since the remainder of this function is used to resolve module paths - if (!options.resolveAsModule) { - return propertyValue; - } + if (!PackageName.isValidName(packageName)) { + throw new Error( + `Module paths are not supported when using the "packageDir" token ` + + (parsedPropertyName ? `of property "${parsedPropertyName}" ` : '') + + `in "${configDir}". Only a package name is allowed.` + ); + } - // Example: @rushstack/heft-jest-plugin - if (propertyValue === PLUGIN_PACKAGE_NAME) { - return PLUGIN_PACKAGE_FOLDER; + // Resolve to the package directory (not the module referenced by the package). The normal resolution + // method will generally not be able to find @rushstack/heft-jest-plugin from a project that is + // using a rig. Since it is important, and it is our own package, we resolve it manually as a special + // case. + const resolvedPackagePath: string = + packageName === PLUGIN_PACKAGE_NAME + ? PLUGIN_PACKAGE_FOLDER + : Import.resolvePackage({ baseFolderPath: configDir, packageName, useNodeJSResolver: true }); + // First entry is the entire match + const restOfPath: string = path.normalize('./' + propertyValue.slice(packageDirMatches[0].length)); + propertyValue = path.resolve(resolvedPackagePath, restOfPath); } + } - // Example: @rushstack/heft-jest-plugin/path/to/file.js - if (propertyValue.startsWith(PLUGIN_PACKAGE_NAME)) { - const restOfPath: string = path.normalize('./' + propertyValue.slice(PLUGIN_PACKAGE_NAME.length)); - return path.join(PLUGIN_PACKAGE_FOLDER, restOfPath); - } + // Return early, since the remainder of this function is used to resolve module paths + if (!options.resolveAsModule) { + return propertyValue; + } - // Use the Jest-provided resolvers to resolve the module paths - switch (parsedPropertyName) { - case 'testRunner': - return resolveRunner(/*resolver:*/ undefined, { - rootDir: configDir, - filePath: propertyValue, - requireResolveFunction - }); + // Example: @rushstack/heft-jest-plugin + if (propertyValue === PLUGIN_PACKAGE_NAME) { + return PLUGIN_PACKAGE_FOLDER; + } - case 'testSequencer': - return resolveSequencer(/*resolver:*/ undefined, { - rootDir: configDir, - filePath: propertyValue, - requireResolveFunction - }); + // Example: @rushstack/heft-jest-plugin/path/to/file.js + if (propertyValue.startsWith(PLUGIN_PACKAGE_NAME)) { + const restOfPath: string = path.normalize('./' + propertyValue.slice(PLUGIN_PACKAGE_NAME.length)); + return path.join(PLUGIN_PACKAGE_FOLDER, restOfPath); + } - case 'testEnvironment': - return resolveTestEnvironment({ - rootDir: configDir, - testEnvironment: propertyValue, - requireResolveFunction - }); + // Use the Jest-provided resolvers to resolve the module paths + switch (parsedPropertyName) { + case 'testRunner': + return resolveRunner(/*resolver:*/ undefined, { + rootDir: configDir, + filePath: propertyValue, + requireResolveFunction + }); - case 'watchPlugins': - return resolveWatchPlugin(/*resolver:*/ undefined, { - rootDir: configDir, - filePath: propertyValue, - requireResolveFunction - }); + case 'testSequencer': + return resolveSequencer(/*resolver:*/ undefined, { + rootDir: configDir, + filePath: propertyValue, + requireResolveFunction + }); - case 'preset': - // Do not allow use of presets and extends together, since that would create a - // confusing hierarchy. - if ( - configurationFile.preset && - (configurationFile as IHeftJestConfigurationWithExtends).extends - ) { - throw new Error( - `The configuration file at "${configurationFilePath}" cannot specify both "preset" and ` + - `"extends" properties.` - ); - } + case 'testEnvironment': + return resolveTestEnvironment({ + rootDir: configDir, + testEnvironment: propertyValue, + requireResolveFunction + }); - // Preset is an odd value, since it can either be a relative path to a preset module - // from the rootDir, or a path to the parent directory of a preset module. So to - // determine which it is, we will attempt to resolve it as a module from the rootDir, - // as per the spec. If it resolves, then we will return the relative path to the - // resolved value from the rootDir. If it does not resolve, then we will return the - // original value to allow Jest to resolve within the target directory. - // See: https://github.com/jestjs/jest/blob/268afca708199c0e64ef26f35995907faf4454ff/packages/jest-config/src/normalize.ts#L123 + case 'watchPlugins': + return resolveWatchPlugin(/*resolver:*/ undefined, { + rootDir: configDir, + filePath: propertyValue, + requireResolveFunction + }); - let resolvedValue: string | null | undefined; - try { - resolvedValue = jestResolve(/*resolver:*/ undefined, { - rootDir: options.rootDir, - filePath: propertyValue, - key: propertyName - }); - } catch (e) { - // Swallow - } - if (resolvedValue) { - // Jest will resolve relative module paths to files only if they use forward slashes. - // They must also start with a '.' otherwise the preset resolution will assume it is a - // folder path and will path.join() it with the default 'jest-preset' filename. - // See: https://github.com/jestjs/jest/blob/268afca708199c0e64ef26f35995907faf4454ff/packages/jest-config/src/normalize.ts#L123 - return Path.convertToSlashes(`./${path.relative(options.rootDir, resolvedValue)}`); - } else { - return propertyValue; - } + case 'preset': + // Do not allow use of presets and extends together, since that would create a + // confusing hierarchy. + if (configurationFile.preset && (configurationFile as IHeftJestConfigurationWithExtends).extends) { + throw new Error( + `The configuration file at "${configurationFilePath}" cannot specify both "preset" and ` + + `"extends" properties.` + ); + } - default: - // We know the value will be non-null since resolve will throw an error if it is null - // and non-optional - return jestResolve(/*resolver:*/ undefined, { - rootDir: configDir, + // Preset is an odd value, since it can either be a relative path to a preset module + // from the rootDir, or a path to the parent directory of a preset module. So to + // determine which it is, we will attempt to resolve it as a module from the rootDir, + // as per the spec. If it resolves, then we will return the relative path to the + // resolved value from the rootDir. If it does not resolve, then we will return the + // original value to allow Jest to resolve within the target directory. + // See: https://github.com/jestjs/jest/blob/268afca708199c0e64ef26f35995907faf4454ff/packages/jest-config/src/normalize.ts#L123 + + let resolvedValue: string | null | undefined; + try { + resolvedValue = jestResolve(/*resolver:*/ undefined, { + rootDir: options.rootDir, filePath: propertyValue, key: propertyName - })!; - } - }, - pathResolutionMethod: PathResolutionMethod.custom - }; - } + }); + } catch (e) { + // Swallow + } + if (resolvedValue) { + // Jest will resolve relative module paths to files only if they use forward slashes. + // They must also start with a '.' otherwise the preset resolution will assume it is a + // folder path and will path.join() it with the default 'jest-preset' filename. + // See: https://github.com/jestjs/jest/blob/268afca708199c0e64ef26f35995907faf4454ff/packages/jest-config/src/normalize.ts#L123 + return Path.convertToSlashes(`./${path.relative(options.rootDir, resolvedValue)}`); + } else { + return propertyValue; + } - /** - * Finds the indices of jest reporters with a given name - */ - private static _findIndexes(items: JestReporterConfig[], search: string): number[] { - const result: number[] = []; + default: + // We know the value will be non-null since resolve will throw an error if it is null + // and non-optional + return jestResolve(/*resolver:*/ undefined, { + rootDir: configDir, + filePath: propertyValue, + key: propertyName + })!; + } + }, + pathResolutionMethod: PathResolutionMethod.custom + }; +} - for (let index: number = 0; index < items.length; index++) { - const item: JestReporterConfig = items[index]; +/** + * Finds the indices of jest reporters with a given name + */ +function _findIndexes(items: JestReporterConfig[], search: string): number[] { + const result: number[] = []; - // Item is either a string or a tuple of [reporterName: string, options: unknown] - if (item === search) { - result.push(index); - } else if (typeof item !== 'undefined' && item !== null && item[0] === search) { - result.push(index); - } - } + for (let index: number = 0; index < items.length; index++) { + const item: JestReporterConfig = items[index]; - return result; + // Item is either a string or a tuple of [reporterName: string, options: unknown] + if (item === search) { + result.push(index); + } else if (typeof item !== 'undefined' && item !== null && item[0] === search) { + result.push(index); + } } + + return result; } diff --git a/libraries/api-extractor-model/src/aedoc/AedocDefinitions.ts b/libraries/api-extractor-model/src/aedoc/AedocDefinitions.ts index a639404d66a..9d4ab635ffa 100644 --- a/libraries/api-extractor-model/src/aedoc/AedocDefinitions.ts +++ b/libraries/api-extractor-model/src/aedoc/AedocDefinitions.ts @@ -3,6 +3,8 @@ import { TSDocConfiguration, TSDocTagDefinition, TSDocTagSyntaxKind, StandardTags } from '@microsoft/tsdoc'; +let _tsdocConfiguration: TSDocConfiguration | undefined; + /** * @internal * @deprecated - tsdoc configuration is now constructed from tsdoc.json files associated with each package. @@ -24,7 +26,7 @@ export class AedocDefinitions { }); public static get tsdocConfiguration(): TSDocConfiguration { - if (!AedocDefinitions._tsdocConfiguration) { + if (!_tsdocConfiguration) { const configuration: TSDocConfiguration = new TSDocConfiguration(); configuration.addTagDefinitions( [ @@ -62,10 +64,8 @@ export class AedocDefinitions { true ); - AedocDefinitions._tsdocConfiguration = configuration; + _tsdocConfiguration = configuration; } - return AedocDefinitions._tsdocConfiguration; + return _tsdocConfiguration; } - - private static _tsdocConfiguration: TSDocConfiguration | undefined; } diff --git a/libraries/lookup-by-path/src/LookupByPath.ts b/libraries/lookup-by-path/src/LookupByPath.ts index 0a524a6d7fb..638cf7274e2 100644 --- a/libraries/lookup-by-path/src/LookupByPath.ts +++ b/libraries/lookup-by-path/src/LookupByPath.ts @@ -323,38 +323,11 @@ export class LookupByPath implements IReadonlyLookupByPath { - for (const prefixMatch of this._iteratePrefixes(serializedPath, delimiter)) { + for (const prefixMatch of _iteratePrefixes(serializedPath, delimiter)) { yield prefixMatch.prefix; } } - private static *_iteratePrefixes(input: string, delimiter: string = '/'): Iterable { - if (!input) { - return; - } - - let previousIndex: number = 0; - let nextIndex: number = input.indexOf(delimiter); - - // Leading segments - while (nextIndex >= 0) { - yield { - prefix: input.slice(previousIndex, nextIndex), - index: nextIndex - }; - previousIndex = nextIndex + 1; - nextIndex = input.indexOf(delimiter, previousIndex); - } - - // Last segment - if (previousIndex < input.length) { - yield { - prefix: input.slice(previousIndex, input.length), - index: input.length - }; - } - } - /** * {@inheritdoc IReadonlyLookupByPath.size} */ @@ -488,7 +461,7 @@ export class LookupByPath implements IReadonlyLookupByPath | undefined { - return this._findLongestPrefixMatch(LookupByPath._iteratePrefixes(query, delimiter)); + return this._findLongestPrefixMatch(_iteratePrefixes(query, delimiter)); } /** @@ -607,9 +580,7 @@ export class LookupByPath implements IReadonlyLookupByPath( - serializeValue: (value: TItem) => TSerialized - ): ILookupByPathJson { + public toJson(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson { const valueToIndex: Map = new Map(); const values: TSerialized[] = []; @@ -671,10 +642,10 @@ export class LookupByPath implements IReadonlyLookupByPath = new LookupByPath(undefined, json.delimiter); - const deserializeNode: ( + const deserializeNode: (jsonNode: ISerializedPathTrieNode, targetNode: IPathTrieNode) => void = ( jsonNode: ISerializedPathTrieNode, targetNode: IPathTrieNode - ) => void = (jsonNode: ISerializedPathTrieNode, targetNode: IPathTrieNode) => { + ) => { if (jsonNode.valueIndex !== undefined) { targetNode.value = deserializedValues[jsonNode.valueIndex]; result._size++; @@ -750,7 +721,7 @@ export class LookupByPath implements IReadonlyLookupByPath | undefined { let node: IPathTrieNode = this._root; - for (const { prefix } of LookupByPath._iteratePrefixes(query, delimiter)) { + for (const { prefix } of _iteratePrefixes(query, delimiter)) { if (!node.children) { return undefined; } @@ -763,3 +734,30 @@ export class LookupByPath implements IReadonlyLookupByPath { + if (!input) { + return; + } + + let previousIndex: number = 0; + let nextIndex: number = input.indexOf(delimiter); + + // Leading segments + while (nextIndex >= 0) { + yield { + prefix: input.slice(previousIndex, nextIndex), + index: nextIndex + }; + previousIndex = nextIndex + 1; + nextIndex = input.indexOf(delimiter, previousIndex); + } + + // Last segment + if (previousIndex < input.length) { + yield { + prefix: input.slice(previousIndex, input.length), + index: input.length + }; + } +} diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 9fb6bb9fd08..29f8669badb 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -202,103 +202,6 @@ export class Async { return result; } - private static async _forEachWeightedAsync( - iterable: AsyncIterable, - callback: (entry: TReturn, arrayIndex: number) => Promise, - options?: IAsyncParallelismOptions | undefined - ): Promise { - await new Promise((resolve: () => void, reject: (error: Error) => void) => { - const concurrency: number = - options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity; - let concurrentUnitsInProgress: number = 0; - - const iterator: Iterator | AsyncIterator = (iterable as AsyncIterable)[ - Symbol.asyncIterator - ].call(iterable); - - let arrayIndex: number = 0; - let iteratorIsComplete: boolean = false; - let promiseHasResolvedOrRejected: boolean = false; - // iterator that is stored when the loop exits early due to not enough concurrency - let nextIterator: IteratorResult | undefined = undefined; - - async function queueOperationsAsync(): Promise { - while ( - concurrentUnitsInProgress < concurrency && - !iteratorIsComplete && - !promiseHasResolvedOrRejected - ) { - // Increment the current concurrency units in progress by the concurrency limit before fetching the iterator weight. - // This function is reentrant, so this if concurrency is finite, at most 1 operation will be waiting. If it's infinite, - // there will be effectively no cap on the number of operations waiting. - const limitedConcurrency: number = !Number.isFinite(concurrency) ? 1 : concurrency; - concurrentUnitsInProgress += limitedConcurrency; - const currentIteratorResult: IteratorResult = nextIterator ?? (await iterator.next()); - // eslint-disable-next-line require-atomic-updates - iteratorIsComplete = !!currentIteratorResult.done; - - if (!iteratorIsComplete) { - const currentIteratorValue: TEntry = currentIteratorResult.value; - Async.validateWeightedIterable(currentIteratorValue); - // Cap the weight to concurrency, this allows 0 weight items to execute despite the concurrency limit. - const weight: number = Math.min(currentIteratorValue.weight, concurrency); - - // Remove the "lock" from the concurrency check and only apply the current weight. - // This should allow other operations to execute. - concurrentUnitsInProgress -= limitedConcurrency; - - // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` - const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; - const allowOversubscription: boolean = options?.allowOversubscription ?? false; - if (!allowOversubscription && wouldExceedConcurrency) { - // eslint-disable-next-line require-atomic-updates - nextIterator = currentIteratorResult; - break; - } - - // eslint-disable-next-line require-atomic-updates - nextIterator = undefined; - concurrentUnitsInProgress += weight; - - Promise.resolve(callback(currentIteratorValue.element, arrayIndex++)) - .then(async () => { - // Remove the operation completely from the in progress units. - concurrentUnitsInProgress -= weight; - await onOperationCompletionAsync(); - }) - .catch((error) => { - promiseHasResolvedOrRejected = true; - reject(error); - }); - } else { - // The iterator is complete and there wasn't a value, so untrack the waiting state. - concurrentUnitsInProgress -= limitedConcurrency; - } - } - - if (iteratorIsComplete) { - await onOperationCompletionAsync(); - } - } - - async function onOperationCompletionAsync(): Promise { - if (!promiseHasResolvedOrRejected) { - if (concurrentUnitsInProgress === 0 && iteratorIsComplete) { - promiseHasResolvedOrRejected = true; - resolve(); - } else if (!iteratorIsComplete) { - await queueOperationsAsync(); - } - } - } - - queueOperationsAsync().catch((error) => { - promiseHasResolvedOrRejected = true; - reject(error); - }); - }); - } - /** * Given an input array and a `callback` function, invoke the callback to start a * promise for each element in the array. @@ -359,7 +262,7 @@ export class Async { callback: (entry: TEntry, arrayIndex: number) => Promise, options?: IAsyncParallelismOptions ): Promise { - await Async._forEachWeightedAsync(toWeightedIterator(iterable, options?.weighted), callback, options); + await _forEachWeightedAsync(toWeightedIterator(iterable, options?.weighted), callback, options); } /** @@ -439,6 +342,103 @@ export class Async { } } +async function _forEachWeightedAsync( + iterable: AsyncIterable, + callback: (entry: TReturn, arrayIndex: number) => Promise, + options?: IAsyncParallelismOptions | undefined +): Promise { + await new Promise((resolve: () => void, reject: (error: Error) => void) => { + const concurrency: number = + options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity; + let concurrentUnitsInProgress: number = 0; + + const iterator: Iterator | AsyncIterator = (iterable as AsyncIterable)[ + Symbol.asyncIterator + ].call(iterable); + + let arrayIndex: number = 0; + let iteratorIsComplete: boolean = false; + let promiseHasResolvedOrRejected: boolean = false; + // iterator that is stored when the loop exits early due to not enough concurrency + let nextIterator: IteratorResult | undefined = undefined; + + async function queueOperationsAsync(): Promise { + while ( + concurrentUnitsInProgress < concurrency && + !iteratorIsComplete && + !promiseHasResolvedOrRejected + ) { + // Increment the current concurrency units in progress by the concurrency limit before fetching the iterator weight. + // This function is reentrant, so this if concurrency is finite, at most 1 operation will be waiting. If it's infinite, + // there will be effectively no cap on the number of operations waiting. + const limitedConcurrency: number = !Number.isFinite(concurrency) ? 1 : concurrency; + concurrentUnitsInProgress += limitedConcurrency; + const currentIteratorResult: IteratorResult = nextIterator ?? (await iterator.next()); + // eslint-disable-next-line require-atomic-updates + iteratorIsComplete = !!currentIteratorResult.done; + + if (!iteratorIsComplete) { + const currentIteratorValue: TEntry = currentIteratorResult.value; + Async.validateWeightedIterable(currentIteratorValue); + // Cap the weight to concurrency, this allows 0 weight items to execute despite the concurrency limit. + const weight: number = Math.min(currentIteratorValue.weight, concurrency); + + // Remove the "lock" from the concurrency check and only apply the current weight. + // This should allow other operations to execute. + concurrentUnitsInProgress -= limitedConcurrency; + + // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` + const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; + const allowOversubscription: boolean = options?.allowOversubscription ?? false; + if (!allowOversubscription && wouldExceedConcurrency) { + // eslint-disable-next-line require-atomic-updates + nextIterator = currentIteratorResult; + break; + } + + // eslint-disable-next-line require-atomic-updates + nextIterator = undefined; + concurrentUnitsInProgress += weight; + + Promise.resolve(callback(currentIteratorValue.element, arrayIndex++)) + .then(async () => { + // Remove the operation completely from the in progress units. + concurrentUnitsInProgress -= weight; + await onOperationCompletionAsync(); + }) + .catch((error) => { + promiseHasResolvedOrRejected = true; + reject(error); + }); + } else { + // The iterator is complete and there wasn't a value, so untrack the waiting state. + concurrentUnitsInProgress -= limitedConcurrency; + } + } + + if (iteratorIsComplete) { + await onOperationCompletionAsync(); + } + } + + async function onOperationCompletionAsync(): Promise { + if (!promiseHasResolvedOrRejected) { + if (concurrentUnitsInProgress === 0 && iteratorIsComplete) { + promiseHasResolvedOrRejected = true; + resolve(); + } else if (!iteratorIsComplete) { + await queueOperationsAsync(); + } + } + } + + queueOperationsAsync().catch((error) => { + promiseHasResolvedOrRejected = true; + reject(error); + }); + }); +} + /** * Returns an unwrapped promise. */ diff --git a/libraries/node-core-library/src/Executable.ts b/libraries/node-core-library/src/Executable.ts index 97edafde57f..bf7931a3221 100644 --- a/libraries/node-core-library/src/Executable.ts +++ b/libraries/node-core-library/src/Executable.ts @@ -456,9 +456,9 @@ export class Executable { options = {}; } - const context: IExecutableContext = Executable._getExecutableContext(options); + const context: IExecutableContext = _getExecutableContext(options); - const resolvedPath: string | undefined = Executable._tryResolve(filename, options, context); + const resolvedPath: string | undefined = _tryResolve(filename, options, context); if (!resolvedPath) { throw new Error(`The executable file was not found: "${filename}"`); } @@ -479,11 +479,7 @@ export class Executable { shell: false }; - const normalizedCommandLine: ICommandLineOptions = Executable._buildCommandLineFixup( - resolvedPath, - args, - context - ); + const normalizedCommandLine: ICommandLineOptions = _buildCommandLineFixup(resolvedPath, args, context); return child_process.spawnSync(normalizedCommandLine.path, normalizedCommandLine.args, spawnOptions); } @@ -520,9 +516,9 @@ export class Executable { options = {}; } - const context: IExecutableContext = Executable._getExecutableContext(options); + const context: IExecutableContext = _getExecutableContext(options); - const resolvedPath: string | undefined = Executable._tryResolve(filename, options, context); + const resolvedPath: string | undefined = _tryResolve(filename, options, context); if (!resolvedPath) { throw new Error(`The executable file was not found: "${filename}"`); } @@ -536,11 +532,7 @@ export class Executable { shell: false }; - const normalizedCommandLine: ICommandLineOptions = Executable._buildCommandLineFixup( - resolvedPath, - args, - context - ); + const normalizedCommandLine: ICommandLineOptions = _buildCommandLineFixup(resolvedPath, args, context); return child_process.spawn(normalizedCommandLine.path, normalizedCommandLine.args, spawnOptions); } @@ -711,79 +703,6 @@ export class Executable { return convertToProcessInfoByNameMap(processInfoByIdMap); } - // PROBLEM: Given an "args" array of strings that may contain special characters (e.g. spaces, - // backslashes, quotes), ensure that these strings pass through to the child process's ARGV array - // without anything getting corrupted along the way. - // - // On Unix you just pass the array to spawnSync(). But on Windows, this is a very complex problem: - // - The Win32 CreateProcess() API expects the args to be encoded as a single text string - // - The decoding of this string is up to the application (not the OS), and there are 3 different - // algorithms in common usage: the cmd.exe shell, the Microsoft CRT library init code, and - // the Win32 CommandLineToArgvW() - // - The encodings are counterintuitive and have lots of special cases - // - NodeJS spawnSync() tries do the encoding without knowing which decoder will be used - // - // See these articles for a full analysis: - // http://www.windowsinspired.com/understanding-the-command-line-string-and-arguments-received-by-a-windows-program/ - // http://www.windowsinspired.com/how-a-windows-programs-splits-its-command-line-into-individual-arguments/ - private static _buildCommandLineFixup( - resolvedPath: string, - args: string[], - context: IExecutableContext - ): ICommandLineOptions { - const fileExtension: string = path.extname(resolvedPath); - - if (OS_PLATFORM === 'win32') { - // Do we need a custom handler for this file type? - switch (fileExtension.toUpperCase()) { - case '.EXE': - case '.COM': - // okay to execute directly - break; - case '.BAT': - case '.CMD': { - Executable._validateArgsForWindowsShell(args); - - // These file types must be invoked via the Windows shell - let shellPath: string | undefined = context.environmentMap.get('COMSPEC'); - if (!shellPath || !Executable._canExecute(shellPath, context)) { - shellPath = Executable.tryResolve('cmd.exe'); - } - if (!shellPath) { - throw new Error( - `Unable to execute "${path.basename(resolvedPath)}" ` + - `because CMD.exe was not found in the PATH` - ); - } - - const shellArgs: string[] = []; - // /D: Disable execution of AutoRun commands when starting the new shell context - shellArgs.push('/d'); - // /S: Disable Cmd.exe's parsing of double-quote characters inside the command-line - shellArgs.push('/s'); - // /C: Execute the following command and then exit immediately - shellArgs.push('/c'); - - // If the path contains special charactrers (e.g. spaces), escape them so that - // they don't get interpreted by the shell - shellArgs.push(Executable._getEscapedForWindowsShell(resolvedPath)); - shellArgs.push(...args); - - return { path: shellPath, args: shellArgs }; - } - default: - throw new Error( - `Cannot execute "${path.basename(resolvedPath)}" because the file type is not supported` - ); - } - } - - return { - path: resolvedPath, - args: args - }; - } - /** * Given a filename, this determines the absolute path of the executable file that would * be executed by a shell: @@ -801,230 +720,300 @@ export class Executable { * @returns the absolute path of the executable, or undefined if it was not found */ public static tryResolve(filename: string, options?: IExecutableResolveOptions): string | undefined { - return Executable._tryResolve(filename, options || {}, Executable._getExecutableContext(options)); + return _tryResolve(filename, options || {}, _getExecutableContext(options)); } +} - private static _tryResolve( - filename: string, - options: IExecutableResolveOptions, - context: IExecutableContext - ): string | undefined { - // NOTE: Since "filename" cannot contain command-line arguments, the "/" here - // must be interpreted as a path delimiter - const hasPathSeparators: boolean = - filename.indexOf('/') >= 0 || (OS_PLATFORM === 'win32' && filename.indexOf('\\') >= 0); - - // Are there any path separators? - if (hasPathSeparators) { - // If so, then don't search the PATH. Just resolve relative to the current working directory - const resolvedPath: string = path.resolve(context.currentWorkingDirectory, filename); - return Executable._tryResolveFileExtension(resolvedPath, context); - } else { - // Otherwise if it's a bare name, then try everything in the shell PATH - const pathsToSearch: string[] = Executable._getSearchFolders(context); - - for (const pathToSearch of pathsToSearch) { - const resolvedPath: string = path.join(pathToSearch, filename); - const result: string | undefined = Executable._tryResolveFileExtension(resolvedPath, context); - if (result) { - return result; - } +function _tryResolve( + filename: string, + options: IExecutableResolveOptions, + context: IExecutableContext +): string | undefined { + // NOTE: Since "filename" cannot contain command-line arguments, the "/" here + // must be interpreted as a path delimiter + const hasPathSeparators: boolean = + filename.indexOf('/') >= 0 || (OS_PLATFORM === 'win32' && filename.indexOf('\\') >= 0); + + // Are there any path separators? + if (hasPathSeparators) { + // If so, then don't search the PATH. Just resolve relative to the current working directory + const resolvedPath: string = path.resolve(context.currentWorkingDirectory, filename); + return _tryResolveFileExtension(resolvedPath, context); + } else { + // Otherwise if it's a bare name, then try everything in the shell PATH + const pathsToSearch: string[] = _getSearchFolders(context); + + for (const pathToSearch of pathsToSearch) { + const resolvedPath: string = path.join(pathToSearch, filename); + const result: string | undefined = _tryResolveFileExtension(resolvedPath, context); + if (result) { + return result; } - - // No match was found - return undefined; } + + // No match was found + return undefined; } +} - private static _tryResolveFileExtension( - resolvedPath: string, - context: IExecutableContext - ): string | undefined { - if (Executable._canExecute(resolvedPath, context)) { - return resolvedPath; - } +function _tryResolveFileExtension(resolvedPath: string, context: IExecutableContext): string | undefined { + if (_canExecute(resolvedPath, context)) { + return resolvedPath; + } - // Try the default file extensions - for (const shellExtension of context.windowsExecutableExtensions) { - const resolvedNameWithExtension: string = resolvedPath + shellExtension; + // Try the default file extensions + for (const shellExtension of context.windowsExecutableExtensions) { + const resolvedNameWithExtension: string = resolvedPath + shellExtension; - if (Executable._canExecute(resolvedNameWithExtension, context)) { - return resolvedNameWithExtension; - } + if (_canExecute(resolvedNameWithExtension, context)) { + return resolvedNameWithExtension; } + } - return undefined; + return undefined; +} + +function _buildEnvironmentMap(options: IExecutableResolveOptions): EnvironmentMap { + const environmentMap: EnvironmentMap = new EnvironmentMap(); + if (options.environment !== undefined && options.environmentMap !== undefined) { + throw new Error( + 'IExecutableResolveOptions.environment and IExecutableResolveOptions.environmentMap' + + ' cannot both be specified' + ); + } + if (options.environment !== undefined) { + environmentMap.mergeFromObject(options.environment); + } else if (options.environmentMap !== undefined) { + environmentMap.mergeFrom(options.environmentMap); + } else { + environmentMap.mergeFromObject(process.env); } + return environmentMap; +} - private static _buildEnvironmentMap(options: IExecutableResolveOptions): EnvironmentMap { - const environmentMap: EnvironmentMap = new EnvironmentMap(); - if (options.environment !== undefined && options.environmentMap !== undefined) { - throw new Error( - 'IExecutableResolveOptions.environment and IExecutableResolveOptions.environmentMap' + - ' cannot both be specified' - ); - } - if (options.environment !== undefined) { - environmentMap.mergeFromObject(options.environment); - } else if (options.environmentMap !== undefined) { - environmentMap.mergeFrom(options.environmentMap); - } else { - environmentMap.mergeFromObject(process.env); - } - return environmentMap; +/** + * This is used when searching the shell PATH for an executable, to determine + * whether a match should be skipped or not. If it returns true, this does not + * guarantee that the file can be successfully executed. + */ +function _canExecute(filePath: string, context: IExecutableContext): boolean { + if (!FileSystem.exists(filePath)) { + return false; } - /** - * This is used when searching the shell PATH for an executable, to determine - * whether a match should be skipped or not. If it returns true, this does not - * guarantee that the file can be successfully executed. - */ - private static _canExecute(filePath: string, context: IExecutableContext): boolean { - if (!FileSystem.exists(filePath)) { + if (OS_PLATFORM === 'win32') { + // NOTE: For Windows, we don't validate that the file extension appears in PATHEXT. + // That environment variable determines which extensions can be appended if the + // extension is missing, but it does not affect whether a file may be executed or not. + // Windows does have a (seldom used) ACL that can be used to deny execution permissions + // for a file, but NodeJS doesn't expose that API, so we don't bother checking it. + + // However, Windows *does* require that the file has some kind of file extension + if (path.extname(filePath) === '') { return false; } - - if (OS_PLATFORM === 'win32') { - // NOTE: For Windows, we don't validate that the file extension appears in PATHEXT. - // That environment variable determines which extensions can be appended if the - // extension is missing, but it does not affect whether a file may be executed or not. - // Windows does have a (seldom used) ACL that can be used to deny execution permissions - // for a file, but NodeJS doesn't expose that API, so we don't bother checking it. - - // However, Windows *does* require that the file has some kind of file extension - if (path.extname(filePath) === '') { - return false; - } - } else { - // For Unix, check whether any of the POSIX execute bits are set - try { - // eslint-disable-next-line no-bitwise - if ((FileSystem.getPosixModeBits(filePath) & PosixModeBits.AllExecute) === 0) { - return false; // not executable - } - } catch (error) { - // If we have trouble accessing the file, ignore the error and consider it "not executable" - // since that's what a shell would do + } else { + // For Unix, check whether any of the POSIX execute bits are set + try { + // eslint-disable-next-line no-bitwise + if ((FileSystem.getPosixModeBits(filePath) & PosixModeBits.AllExecute) === 0) { + return false; // not executable } + } catch (error) { + // If we have trouble accessing the file, ignore the error and consider it "not executable" + // since that's what a shell would do } - - return true; } - /** - * Returns the list of folders where we will search for an executable, - * based on the PATH environment variable. - */ - private static _getSearchFolders(context: IExecutableContext): string[] { - const pathList: string = context.environmentMap.get('PATH') || ''; - - const folders: string[] = []; - - // Avoid processing duplicates - const seenPaths: Set = new Set(); - - // NOTE: Cmd.exe on Windows always searches the current working directory first. - // PowerShell and Unix shells do NOT do that, because it's a security concern. - // We follow their behavior. - - for (const splitPath of pathList.split(path.delimiter)) { - const trimmedPath: string = splitPath.trim(); - if (trimmedPath !== '') { - if (!seenPaths.has(trimmedPath)) { - // Fun fact: If you put relative paths in your PATH environment variable, - // all shells will dynamically match them against the current working directory. - // This is a terrible design, and in practice nobody does that, but it is supported... - // so we allow it here. - const resolvedPath: string = path.resolve(context.currentWorkingDirectory, trimmedPath); - - if (!seenPaths.has(resolvedPath)) { - if (FileSystem.exists(resolvedPath)) { - folders.push(resolvedPath); - } - - seenPaths.add(resolvedPath); + return true; +} + +/** + * Returns the list of folders where we will search for an executable, + * based on the PATH environment variable. + */ +function _getSearchFolders(context: IExecutableContext): string[] { + const pathList: string = context.environmentMap.get('PATH') || ''; + + const folders: string[] = []; + + // Avoid processing duplicates + const seenPaths: Set = new Set(); + + // NOTE: Cmd.exe on Windows always searches the current working directory first. + // PowerShell and Unix shells do NOT do that, because it's a security concern. + // We follow their behavior. + + for (const splitPath of pathList.split(path.delimiter)) { + const trimmedPath: string = splitPath.trim(); + if (trimmedPath !== '') { + if (!seenPaths.has(trimmedPath)) { + // Fun fact: If you put relative paths in your PATH environment variable, + // all shells will dynamically match them against the current working directory. + // This is a terrible design, and in practice nobody does that, but it is supported... + // so we allow it here. + const resolvedPath: string = path.resolve(context.currentWorkingDirectory, trimmedPath); + + if (!seenPaths.has(resolvedPath)) { + if (FileSystem.exists(resolvedPath)) { + folders.push(resolvedPath); } - seenPaths.add(trimmedPath); + seenPaths.add(resolvedPath); } + + seenPaths.add(trimmedPath); } } + } - return folders; + return folders; +} + +function _getExecutableContext(options: IExecutableResolveOptions | undefined): IExecutableContext { + if (!options) { + options = {}; } - private static _getExecutableContext(options: IExecutableResolveOptions | undefined): IExecutableContext { - if (!options) { - options = {}; - } + const environment: EnvironmentMap = _buildEnvironmentMap(options); - const environment: EnvironmentMap = Executable._buildEnvironmentMap(options); + let currentWorkingDirectory: string; + if (options.currentWorkingDirectory) { + currentWorkingDirectory = path.resolve(options.currentWorkingDirectory); + } else { + currentWorkingDirectory = process.cwd(); + } - let currentWorkingDirectory: string; - if (options.currentWorkingDirectory) { - currentWorkingDirectory = path.resolve(options.currentWorkingDirectory); - } else { - currentWorkingDirectory = process.cwd(); - } + const windowsExecutableExtensions: string[] = []; - const windowsExecutableExtensions: string[] = []; - - if (OS_PLATFORM === 'win32') { - const pathExtVariable: string = environment.get('PATHEXT') || ''; - for (const splitValue of pathExtVariable.split(';')) { - const trimmed: string = splitValue.trim().toLowerCase(); - // Ignore malformed extensions - if (/^\.[a-z0-9\.]*[a-z0-9]$/i.test(trimmed)) { - // Don't add the same extension twice - if (windowsExecutableExtensions.indexOf(trimmed) < 0) { - windowsExecutableExtensions.push(trimmed); - } + if (OS_PLATFORM === 'win32') { + const pathExtVariable: string = environment.get('PATHEXT') || ''; + for (const splitValue of pathExtVariable.split(';')) { + const trimmed: string = splitValue.trim().toLowerCase(); + // Ignore malformed extensions + if (/^\.[a-z0-9\.]*[a-z0-9]$/i.test(trimmed)) { + // Don't add the same extension twice + if (windowsExecutableExtensions.indexOf(trimmed) < 0) { + windowsExecutableExtensions.push(trimmed); } } } - - return { - environmentMap: environment, - currentWorkingDirectory, - windowsExecutableExtensions - }; } - /** - * Given an input string containing special symbol characters, this inserts the "^" escape - * character to ensure the symbols are interpreted literally by the Windows shell. - */ - private static _getEscapedForWindowsShell(text: string): string { - const escapableCharRegExp: RegExp = /[%\^&|<> ]/g; - return text.replace(escapableCharRegExp, (value) => '^' + value); + return { + environmentMap: environment, + currentWorkingDirectory, + windowsExecutableExtensions + }; +} + +/** + * Given an input string containing special symbol characters, this inserts the "^" escape + * character to ensure the symbols are interpreted literally by the Windows shell. + */ +function _getEscapedForWindowsShell(text: string): string { + const escapableCharRegExp: RegExp = /[%\^&|<> ]/g; + return text.replace(escapableCharRegExp, (value) => '^' + value); +} + +/** + * Checks for characters that are unsafe to pass to a Windows batch file + * due to the way that cmd.exe implements escaping. + */ +function _validateArgsForWindowsShell(args: string[]): void { + const specialCharRegExp: RegExp = /[%\^&|<>\r\n]/g; + + for (const arg of args) { + const match: RegExpMatchArray | null = arg.match(specialCharRegExp); + if (match) { + // NOTE: It is possible to escape some of these characters by prefixing them + // with a caret (^), which allows these characters to be successfully passed + // through to the batch file %1 variables. But they will be expanded again + // whenever they are used. For example, NPM's binary wrapper batch files + // use "%*" to pass their arguments to Node.exe, which causes them to be expanded + // again. Unfortunately the Cmd.exe batch language provides native escaping + // function (that could be used to insert the carets again). + // + // We could work around that by adding double carets, but in general there + // is no way to predict how many times the variable will get expanded. + // Thus, there is no generally reliable way to pass these characters. + throw new Error( + `The command line argument ${JSON.stringify(arg)} contains a` + + ` special character ${JSON.stringify(match[0])} that cannot be escaped for the Windows shell` + ); + } } +} - /** - * Checks for characters that are unsafe to pass to a Windows batch file - * due to the way that cmd.exe implements escaping. - */ - private static _validateArgsForWindowsShell(args: string[]): void { - const specialCharRegExp: RegExp = /[%\^&|<>\r\n]/g; - - for (const arg of args) { - const match: RegExpMatchArray | null = arg.match(specialCharRegExp); - if (match) { - // NOTE: It is possible to escape some of these characters by prefixing them - // with a caret (^), which allows these characters to be successfully passed - // through to the batch file %1 variables. But they will be expanded again - // whenever they are used. For example, NPM's binary wrapper batch files - // use "%*" to pass their arguments to Node.exe, which causes them to be expanded - // again. Unfortunately the Cmd.exe batch language provides native escaping - // function (that could be used to insert the carets again). - // - // We could work around that by adding double carets, but in general there - // is no way to predict how many times the variable will get expanded. - // Thus, there is no generally reliable way to pass these characters. +// PROBLEM: Given an "args" array of strings that may contain special characters (e.g. spaces, +// backslashes, quotes), ensure that these strings pass through to the child process's ARGV array +// without anything getting corrupted along the way. +// +// On Unix you just pass the array to spawnSync(). But on Windows, this is a very complex problem: +// - The Win32 CreateProcess() API expects the args to be encoded as a single text string +// - The decoding of this string is up to the application (not the OS), and there are 3 different +// algorithms in common usage: the cmd.exe shell, the Microsoft CRT library init code, and +// the Win32 CommandLineToArgvW() +// - The encodings are counterintuitive and have lots of special cases +// - NodeJS spawnSync() tries do the encoding without knowing which decoder will be used +// +// See these articles for a full analysis: +// http://www.windowsinspired.com/understanding-the-command-line-string-and-arguments-received-by-a-windows-program/ +// http://www.windowsinspired.com/how-a-windows-programs-splits-its-command-line-into-individual-arguments/ +function _buildCommandLineFixup( + resolvedPath: string, + args: string[], + context: IExecutableContext +): ICommandLineOptions { + const fileExtension: string = path.extname(resolvedPath); + + if (OS_PLATFORM === 'win32') { + // Do we need a custom handler for this file type? + switch (fileExtension.toUpperCase()) { + case '.EXE': + case '.COM': + // okay to execute directly + break; + case '.BAT': + case '.CMD': { + _validateArgsForWindowsShell(args); + + // These file types must be invoked via the Windows shell + let shellPath: string | undefined = context.environmentMap.get('COMSPEC'); + if (!shellPath || !_canExecute(shellPath, context)) { + shellPath = Executable.tryResolve('cmd.exe'); + } + if (!shellPath) { + throw new Error( + `Unable to execute "${path.basename(resolvedPath)}" ` + + `because CMD.exe was not found in the PATH` + ); + } + + const shellArgs: string[] = []; + // /D: Disable execution of AutoRun commands when starting the new shell context + shellArgs.push('/d'); + // /S: Disable Cmd.exe's parsing of double-quote characters inside the command-line + shellArgs.push('/s'); + // /C: Execute the following command and then exit immediately + shellArgs.push('/c'); + + // If the path contains special charactrers (e.g. spaces), escape them so that + // they don't get interpreted by the shell + shellArgs.push(_getEscapedForWindowsShell(resolvedPath)); + shellArgs.push(...args); + + return { path: shellPath, args: shellArgs }; + } + default: throw new Error( - `The command line argument ${JSON.stringify(arg)} contains a` + - ` special character ${JSON.stringify(match[0])} that cannot be escaped for the Windows shell` + `Cannot execute "${path.basename(resolvedPath)}" because the file type is not supported` ); - } } } + + return { + path: resolvedPath, + args: args + }; } diff --git a/libraries/node-core-library/src/FileError.ts b/libraries/node-core-library/src/FileError.ts index 3b25396c672..4de451cd7ab 100644 --- a/libraries/node-core-library/src/FileError.ts +++ b/libraries/node-core-library/src/FileError.ts @@ -70,6 +70,15 @@ const vsProblemMatcherPattern: IProblemPattern = { message: 6 }; +const _environmentVariableBasePathFnMap: ReadonlyMap< + string | undefined, + (fileError: FileError) => string | undefined +> = new Map([ + [undefined, (fileError: FileError) => fileError.projectFolder], + ['{PROJECT_FOLDER}', (fileError: FileError) => fileError.projectFolder], + ['{ABSOLUTE_PATH}', (fileError: FileError) => undefined as string | undefined] +]); + /** * An `Error` subclass that should be thrown to report an unexpected state that specifically references * a location in a file. @@ -85,15 +94,6 @@ export class FileError extends Error { /** @internal */ public static _environmentVariableIsAbsolutePath: boolean = false; - private static _environmentVariableBasePathFnMap: ReadonlyMap< - string | undefined, - (fileError: FileError) => string | undefined - > = new Map([ - [undefined, (fileError: FileError) => fileError.projectFolder], - ['{PROJECT_FOLDER}', (fileError: FileError) => fileError.projectFolder], - ['{ABSOLUTE_PATH}', (fileError: FileError) => undefined as string | undefined] - ]); - /** {@inheritdoc IFileErrorOptions.absolutePath} */ public readonly absolutePath: string; /** {@inheritdoc IFileErrorOptions.projectFolder} */ @@ -183,7 +183,7 @@ export class FileError extends Error { // undefined environment variable has a mapping to the project folder const baseFolderFn: ((fileError: FileError) => string | undefined) | undefined = - FileError._environmentVariableBasePathFnMap.get(FileError._sanitizedEnvironmentVariable); + _environmentVariableBasePathFnMap.get(FileError._sanitizedEnvironmentVariable); if (baseFolderFn) { return baseFolderFn(this); } diff --git a/libraries/node-core-library/src/FileSystem.ts b/libraries/node-core-library/src/FileSystem.ts index 0975847f35d..7eff1995b8b 100644 --- a/libraries/node-core-library/src/FileSystem.ts +++ b/libraries/node-core-library/src/FileSystem.ts @@ -410,7 +410,7 @@ export class FileSystem { * @param path - The absolute or relative path to the filesystem object. */ public static exists(path: string): boolean { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fsx.existsSync(path); }); } @@ -419,7 +419,7 @@ export class FileSystem { * An async version of {@link FileSystem.exists}. */ public static async existsAsync(path: string): Promise { - return await FileSystem._wrapExceptionAsync(() => { + return await _wrapExceptionAsync(() => { return new Promise((resolve: (result: boolean) => void) => { fsx.exists(path, resolve); }); @@ -433,7 +433,7 @@ export class FileSystem { * @param path - The absolute or relative path to the filesystem object. */ public static getStatistics(path: string): FileSystemStats { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fsx.statSync(path); }); } @@ -442,7 +442,7 @@ export class FileSystem { * An async version of {@link FileSystem.getStatistics}. */ public static async getStatisticsAsync(path: string): Promise { - return await FileSystem._wrapExceptionAsync(() => { + return await _wrapExceptionAsync(() => { return fsx.stat(path); }); } @@ -455,7 +455,7 @@ export class FileSystem { * @param times - The times that the object should be updated to reflect. */ public static updateTimes(path: string, times: IFileSystemUpdateTimeParameters): void { - return FileSystem._wrapException(() => { + return _wrapException(() => { fsx.utimesSync(path, times.accessedTime, times.modifiedTime); }); } @@ -464,7 +464,7 @@ export class FileSystem { * An async version of {@link FileSystem.updateTimes}. */ public static async updateTimesAsync(path: string, times: IFileSystemUpdateTimeParameters): Promise { - await FileSystem._wrapExceptionAsync(() => { + await _wrapExceptionAsync(() => { // This cast is needed because the fs-extra typings require both parameters // to have the same type (number or Date), whereas Node.js does not require that. return fsx.utimes(path, times.accessedTime as number, times.modifiedTime as number); @@ -478,7 +478,7 @@ export class FileSystem { * @param modeBits - POSIX-style file mode bits specified using the {@link PosixModeBits} enum */ public static changePosixModeBits(path: string, modeBits: PosixModeBits): void { - FileSystem._wrapException(() => { + _wrapException(() => { fs.chmodSync(path, modeBits); }); } @@ -487,7 +487,7 @@ export class FileSystem { * An async version of {@link FileSystem.changePosixModeBits}. */ public static async changePosixModeBitsAsync(path: string, mode: PosixModeBits): Promise { - await FileSystem._wrapExceptionAsync(() => { + await _wrapExceptionAsync(() => { return fsx.chmod(path, mode); }); } @@ -503,7 +503,7 @@ export class FileSystem { * to call {@link FileSystem.getStatistics} directly instead. */ public static getPosixModeBits(path: string): PosixModeBits { - return FileSystem._wrapException(() => { + return _wrapException(() => { return FileSystem.getStatistics(path).mode; }); } @@ -512,7 +512,7 @@ export class FileSystem { * An async version of {@link FileSystem.getPosixModeBits}. */ public static async getPosixModeBitsAsync(path: string): Promise { - return await FileSystem._wrapExceptionAsync(async () => { + return await _wrapExceptionAsync(async () => { return (await FileSystem.getStatisticsAsync(path)).mode; }); } @@ -547,7 +547,7 @@ export class FileSystem { * Behind the scenes it uses `fs-extra.moveSync()` */ public static move(options: IFileSystemMoveOptions): void { - FileSystem._wrapException(() => { + _wrapException(() => { options = { ...MOVE_DEFAULT_OPTIONS, ...options @@ -575,7 +575,7 @@ export class FileSystem { * An async version of {@link FileSystem.move}. */ public static async moveAsync(options: IFileSystemMoveOptions): Promise { - await FileSystem._wrapExceptionAsync(async () => { + await _wrapExceptionAsync(async () => { options = { ...MOVE_DEFAULT_OPTIONS, ...options @@ -611,7 +611,7 @@ export class FileSystem { * @param folderPath - The absolute or relative path of the folder which should be created. */ public static ensureFolder(folderPath: string): void { - FileSystem._wrapException(() => { + _wrapException(() => { fsx.ensureDirSync(folderPath); }); } @@ -620,7 +620,7 @@ export class FileSystem { * An async version of {@link FileSystem.ensureFolder}. */ public static async ensureFolderAsync(folderPath: string): Promise { - await FileSystem._wrapExceptionAsync(() => { + await _wrapExceptionAsync(() => { return fsx.ensureDir(folderPath); }); } @@ -632,7 +632,7 @@ export class FileSystem { * @param options - Optional settings that can change the behavior. Type: `IReadFolderOptions` */ public static readFolderItemNames(folderPath: string, options?: IFileSystemReadFolderOptions): string[] { - return FileSystem._wrapException(() => { + return _wrapException(() => { options = { ...READ_FOLDER_DEFAULT_OPTIONS, ...options @@ -654,7 +654,7 @@ export class FileSystem { folderPath: string, options?: IFileSystemReadFolderOptions ): Promise { - return await FileSystem._wrapExceptionAsync(async () => { + return await _wrapExceptionAsync(async () => { options = { ...READ_FOLDER_DEFAULT_OPTIONS, ...options @@ -677,7 +677,7 @@ export class FileSystem { * @param options - Optional settings that can change the behavior. Type: `IReadFolderOptions` */ public static readFolderItems(folderPath: string, options?: IFileSystemReadFolderOptions): FolderItem[] { - return FileSystem._wrapException(() => { + return _wrapException(() => { options = { ...READ_FOLDER_DEFAULT_OPTIONS, ...options @@ -702,7 +702,7 @@ export class FileSystem { folderPath: string, options?: IFileSystemReadFolderOptions ): Promise { - return await FileSystem._wrapExceptionAsync(async () => { + return await _wrapExceptionAsync(async () => { options = { ...READ_FOLDER_DEFAULT_OPTIONS, ...options @@ -728,7 +728,7 @@ export class FileSystem { * @param folderPath - The absolute or relative path to the folder which should be deleted. */ public static deleteFolder(folderPath: string): void { - FileSystem._wrapException(() => { + _wrapException(() => { fsx.removeSync(folderPath); }); } @@ -737,7 +737,7 @@ export class FileSystem { * An async version of {@link FileSystem.deleteFolder}. */ public static async deleteFolderAsync(folderPath: string): Promise { - await FileSystem._wrapExceptionAsync(() => { + await _wrapExceptionAsync(() => { return fsx.remove(folderPath); }); } @@ -751,7 +751,7 @@ export class FileSystem { * @param folderPath - The absolute or relative path to the folder which should have its contents deleted. */ public static ensureEmptyFolder(folderPath: string): void { - FileSystem._wrapException(() => { + _wrapException(() => { fsx.emptyDirSync(folderPath); }); } @@ -760,7 +760,7 @@ export class FileSystem { * An async version of {@link FileSystem.ensureEmptyFolder}. */ public static async ensureEmptyFolderAsync(folderPath: string): Promise { - await FileSystem._wrapExceptionAsync(() => { + await _wrapExceptionAsync(() => { return fsx.emptyDir(folderPath); }); } @@ -784,7 +784,7 @@ export class FileSystem { contents: string | Buffer, options?: IFileSystemWriteFileOptions ): void { - FileSystem._wrapException(() => { + _wrapException(() => { options = { ...WRITE_FILE_DEFAULT_OPTIONS, ...options @@ -831,7 +831,7 @@ export class FileSystem { contents: ReadonlyArray, options?: IFileSystemWriteBinaryFileOptions ): void { - FileSystem._wrapException(() => { + _wrapException(() => { // Need a mutable copy of the iterable to handle incomplete writes, // since writev() doesn't take an argument for where to start writing. const toCopy: NodeJS.ArrayBufferView[] = [...contents]; @@ -890,7 +890,7 @@ export class FileSystem { contents: string | Buffer, options?: IFileSystemWriteFileOptions ): Promise { - await FileSystem._wrapExceptionAsync(async () => { + await _wrapExceptionAsync(async () => { options = { ...WRITE_FILE_DEFAULT_OPTIONS, ...options @@ -926,7 +926,7 @@ export class FileSystem { contents: ReadonlyArray, options?: IFileSystemWriteBinaryFileOptions ): Promise { - await FileSystem._wrapExceptionAsync(async () => { + await _wrapExceptionAsync(async () => { // Need a mutable copy of the iterable to handle incomplete writes, // since writev() doesn't take an argument for where to start writing. const toCopy: NodeJS.ArrayBufferView[] = [...contents]; @@ -992,7 +992,7 @@ export class FileSystem { contents: string | Buffer, options?: IFileSystemWriteFileOptions ): void { - FileSystem._wrapException(() => { + _wrapException(() => { options = { ...APPEND_TO_FILE_DEFAULT_OPTIONS, ...options @@ -1028,7 +1028,7 @@ export class FileSystem { contents: string | Buffer, options?: IFileSystemWriteFileOptions ): Promise { - await FileSystem._wrapExceptionAsync(async () => { + await _wrapExceptionAsync(async () => { options = { ...APPEND_TO_FILE_DEFAULT_OPTIONS, ...options @@ -1063,7 +1063,7 @@ export class FileSystem { * @param options - Optional settings that can change the behavior. Type: `IReadFileOptions` */ public static readFile(filePath: string, options?: IFileSystemReadFileOptions): string { - return FileSystem._wrapException(() => { + return _wrapException(() => { options = { ...READ_FILE_DEFAULT_OPTIONS, ...options @@ -1082,7 +1082,7 @@ export class FileSystem { * An async version of {@link FileSystem.readFile}. */ public static async readFileAsync(filePath: string, options?: IFileSystemReadFileOptions): Promise { - return await FileSystem._wrapExceptionAsync(async () => { + return await _wrapExceptionAsync(async () => { options = { ...READ_FILE_DEFAULT_OPTIONS, ...options @@ -1103,7 +1103,7 @@ export class FileSystem { * @param filePath - The relative or absolute path to the file whose contents should be read. */ public static readFileToBuffer(filePath: string): Buffer { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fsx.readFileSync(filePath); }); } @@ -1112,7 +1112,7 @@ export class FileSystem { * An async version of {@link FileSystem.readFileToBuffer}. */ public static async readFileToBufferAsync(filePath: string): Promise { - return await FileSystem._wrapExceptionAsync(() => { + return await _wrapExceptionAsync(() => { return fsx.readFile(filePath); }); } @@ -1139,7 +1139,7 @@ export class FileSystem { ); } - FileSystem._wrapException(() => { + _wrapException(() => { fsx.copySync(options.sourcePath, options.destinationPath, { errorOnExist: options.alreadyExistsBehavior === AlreadyExistsBehavior.Error, overwrite: options.alreadyExistsBehavior === AlreadyExistsBehavior.Overwrite @@ -1162,7 +1162,7 @@ export class FileSystem { ); } - await FileSystem._wrapExceptionAsync(() => { + await _wrapExceptionAsync(() => { return fsx.copy(options.sourcePath, options.destinationPath, { errorOnExist: options.alreadyExistsBehavior === AlreadyExistsBehavior.Error, overwrite: options.alreadyExistsBehavior === AlreadyExistsBehavior.Overwrite @@ -1186,7 +1186,7 @@ export class FileSystem { ...options }; - FileSystem._wrapException(() => { + _wrapException(() => { fsx.copySync(options.sourcePath, options.destinationPath, { dereference: !!options.dereferenceSymlinks, errorOnExist: options.alreadyExistsBehavior === AlreadyExistsBehavior.Error, @@ -1206,7 +1206,7 @@ export class FileSystem { ...options }; - await FileSystem._wrapExceptionAsync(async () => { + await _wrapExceptionAsync(async () => { await fsx.copy(options.sourcePath, options.destinationPath, { dereference: !!options.dereferenceSymlinks, errorOnExist: options.alreadyExistsBehavior === AlreadyExistsBehavior.Error, @@ -1224,7 +1224,7 @@ export class FileSystem { * @param options - Optional settings that can change the behavior. Type: `IDeleteFileOptions` */ public static deleteFile(filePath: string, options?: IFileSystemDeleteFileOptions): void { - FileSystem._wrapException(() => { + _wrapException(() => { options = { ...DELETE_FILE_DEFAULT_OPTIONS, ...options @@ -1247,7 +1247,7 @@ export class FileSystem { filePath: string, options?: IFileSystemDeleteFileOptions ): Promise { - await FileSystem._wrapExceptionAsync(async () => { + await _wrapExceptionAsync(async () => { options = { ...DELETE_FILE_DEFAULT_OPTIONS, ...options @@ -1271,7 +1271,7 @@ export class FileSystem { * @returns A new readable stream for the file. */ public static createReadStream(filePath: string): FileSystemReadStream { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fs.createReadStream(filePath); }); } @@ -1291,7 +1291,7 @@ export class FileSystem { filePath: string, options?: IFileSystemCreateWriteStreamOptions ): FileSystemWriteStream { - return FileSystem._wrapException(() => { + return _wrapException(() => { if (options?.ensureFolderExists) { const folderPath: string = nodeJsPath.dirname(filePath); FileSystem.ensureFolder(folderPath); @@ -1308,7 +1308,7 @@ export class FileSystem { filePath: string, options?: IFileSystemCreateWriteStreamOptions ): Promise { - return await FileSystem._wrapExceptionAsync(async () => { + return await _wrapExceptionAsync(async () => { if (options?.ensureFolderExists) { const folderPath: string = nodeJsPath.dirname(filePath); await FileSystem.ensureFolderAsync(folderPath); @@ -1328,7 +1328,7 @@ export class FileSystem { * @param path - The absolute or relative path to the filesystem object. */ public static getLinkStatistics(path: string): FileSystemStats { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fsx.lstatSync(path); }); } @@ -1337,7 +1337,7 @@ export class FileSystem { * An async version of {@link FileSystem.getLinkStatistics}. */ public static async getLinkStatisticsAsync(path: string): Promise { - return await FileSystem._wrapExceptionAsync(() => { + return await _wrapExceptionAsync(() => { return fsx.lstat(path); }); } @@ -1354,7 +1354,7 @@ export class FileSystem { * @returns the path of the link target */ public static readLink(path: string): string { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fsx.readlinkSync(path); }); } @@ -1363,7 +1363,7 @@ export class FileSystem { * An async version of {@link FileSystem.readLink}. */ public static async readLinkAsync(path: string): Promise { - return await FileSystem._wrapExceptionAsync(() => { + return await _wrapExceptionAsync(() => { return fsx.readlink(path); }); } @@ -1386,8 +1386,8 @@ export class FileSystem { * if not, use a symbolic link instead. */ public static createSymbolicLinkJunction(options: IFileSystemCreateLinkOptions): void { - FileSystem._wrapException(() => { - return FileSystem._handleLink(() => { + _wrapException(() => { + return _handleLink(() => { // For directories, we use a Windows "junction". On POSIX operating systems, this produces a regular symlink. return fsx.symlinkSync(options.linkTargetPath, options.newLinkPath, 'junction'); }, options); @@ -1398,8 +1398,8 @@ export class FileSystem { * An async version of {@link FileSystem.createSymbolicLinkJunction}. */ public static async createSymbolicLinkJunctionAsync(options: IFileSystemCreateLinkOptions): Promise { - await FileSystem._wrapExceptionAsync(() => { - return FileSystem._handleLinkAsync(() => { + await _wrapExceptionAsync(() => { + return _handleLinkAsync(() => { // For directories, we use a Windows "junction". On POSIX operating systems, this produces a regular symlink. return fsx.symlink(options.linkTargetPath, options.newLinkPath, 'junction'); }, options); @@ -1420,8 +1420,8 @@ export class FileSystem { * tool incompatible with Windows. */ public static createSymbolicLinkFile(options: IFileSystemCreateLinkOptions): void { - FileSystem._wrapException(() => { - return FileSystem._handleLink(() => { + _wrapException(() => { + return _handleLink(() => { return fsx.symlinkSync(options.linkTargetPath, options.newLinkPath, 'file'); }, options); }); @@ -1431,8 +1431,8 @@ export class FileSystem { * An async version of {@link FileSystem.createSymbolicLinkFile}. */ public static async createSymbolicLinkFileAsync(options: IFileSystemCreateLinkOptions): Promise { - await FileSystem._wrapExceptionAsync(() => { - return FileSystem._handleLinkAsync(() => { + await _wrapExceptionAsync(() => { + return _handleLinkAsync(() => { return fsx.symlink(options.linkTargetPath, options.newLinkPath, 'file'); }, options); }); @@ -1452,8 +1452,8 @@ export class FileSystem { * tool incompatible with Windows. */ public static createSymbolicLinkFolder(options: IFileSystemCreateLinkOptions): void { - FileSystem._wrapException(() => { - return FileSystem._handleLink(() => { + _wrapException(() => { + return _handleLink(() => { return fsx.symlinkSync(options.linkTargetPath, options.newLinkPath, 'dir'); }, options); }); @@ -1463,8 +1463,8 @@ export class FileSystem { * An async version of {@link FileSystem.createSymbolicLinkFolder}. */ public static async createSymbolicLinkFolderAsync(options: IFileSystemCreateLinkOptions): Promise { - await FileSystem._wrapExceptionAsync(() => { - return FileSystem._handleLinkAsync(() => { + await _wrapExceptionAsync(() => { + return _handleLinkAsync(() => { return fsx.symlink(options.linkTargetPath, options.newLinkPath, 'dir'); }, options); }); @@ -1487,8 +1487,8 @@ export class FileSystem { * if not, use a symbolic link instead. */ public static createHardLink(options: IFileSystemCreateLinkOptions): void { - FileSystem._wrapException(() => { - return FileSystem._handleLink( + _wrapException(() => { + return _handleLink( () => { return fsx.linkSync(options.linkTargetPath, options.newLinkPath); }, @@ -1501,8 +1501,8 @@ export class FileSystem { * An async version of {@link FileSystem.createHardLink}. */ public static async createHardLinkAsync(options: IFileSystemCreateLinkOptions): Promise { - await FileSystem._wrapExceptionAsync(() => { - return FileSystem._handleLinkAsync( + await _wrapExceptionAsync(() => { + return _handleLinkAsync( () => { return fsx.link(options.linkTargetPath, options.newLinkPath); }, @@ -1517,7 +1517,7 @@ export class FileSystem { * @param linkPath - The path to the link. */ public static getRealPath(linkPath: string): string { - return FileSystem._wrapException(() => { + return _wrapException(() => { return fsx.realpathSync(linkPath); }); } @@ -1526,7 +1526,7 @@ export class FileSystem { * An async version of {@link FileSystem.getRealPath}. */ public static async getRealPathAsync(linkPath: string): Promise { - return await FileSystem._wrapExceptionAsync(() => { + return await _wrapExceptionAsync(() => { return fsx.realpath(linkPath); }); } @@ -1598,156 +1598,156 @@ export class FileSystem { typeof typedError.syscall === 'string' ); } +} - private static _handleLinkExistError( - linkFn: () => void, - options: IInternalFileSystemCreateLinkOptions, - error: Error - ): void { - switch (options.alreadyExistsBehavior) { - case AlreadyExistsBehavior.Ignore: - break; - case AlreadyExistsBehavior.Overwrite: - // fsx.linkSync does not allow overwriting so we must manually delete. If it's - // a folder, it will throw an error. - this.deleteFile(options.newLinkPath); - linkFn(); - break; - case AlreadyExistsBehavior.Error: - default: - throw error; - } +function _handleLinkExistError( + linkFn: () => void, + options: IInternalFileSystemCreateLinkOptions, + error: Error +): void { + switch (options.alreadyExistsBehavior) { + case AlreadyExistsBehavior.Ignore: + break; + case AlreadyExistsBehavior.Overwrite: + // fsx.linkSync does not allow overwriting so we must manually delete. If it's + // a folder, it will throw an error. + FileSystem.deleteFile(options.newLinkPath); + linkFn(); + break; + case AlreadyExistsBehavior.Error: + default: + throw error; } +} - private static _handleLink(linkFn: () => void, options: IInternalFileSystemCreateLinkOptions): void { - try { - linkFn(); - } catch (error) { - if (FileSystem.isExistError(error as Error)) { - // Link exists, handle it - FileSystem._handleLinkExistError(linkFn, options, error as Error); - } else { - // When attempting to create a link in a directory that does not exist, an ENOENT - // or ENOTDIR error is thrown, so we should ensure the directory exists before - // retrying. There are also cases where the target file must exist, so validate in - // those cases to avoid confusing the missing directory with the missing target file. - if ( - FileSystem.isNotExistError(error as Error) && - (!options.linkTargetMustExist || FileSystem.exists(options.linkTargetPath)) - ) { - this.ensureFolder(nodeJsPath.dirname(options.newLinkPath)); - try { - linkFn(); - } catch (retryError) { - if (FileSystem.isExistError(retryError as Error)) { - // Another concurrent process may have created the link between the ensureFolder - // call and the retry; handle it the same way as the initial exist error. - FileSystem._handleLinkExistError(linkFn, options, retryError as Error); - } else { - throw retryError; - } +function _handleLink(linkFn: () => void, options: IInternalFileSystemCreateLinkOptions): void { + try { + linkFn(); + } catch (error) { + if (FileSystem.isExistError(error as Error)) { + // Link exists, handle it + _handleLinkExistError(linkFn, options, error as Error); + } else { + // When attempting to create a link in a directory that does not exist, an ENOENT + // or ENOTDIR error is thrown, so we should ensure the directory exists before + // retrying. There are also cases where the target file must exist, so validate in + // those cases to avoid confusing the missing directory with the missing target file. + if ( + FileSystem.isNotExistError(error as Error) && + (!options.linkTargetMustExist || FileSystem.exists(options.linkTargetPath)) + ) { + FileSystem.ensureFolder(nodeJsPath.dirname(options.newLinkPath)); + try { + linkFn(); + } catch (retryError) { + if (FileSystem.isExistError(retryError as Error)) { + // Another concurrent process may have created the link between the ensureFolder + // call and the retry; handle it the same way as the initial exist error. + _handleLinkExistError(linkFn, options, retryError as Error); + } else { + throw retryError; } - } else { - throw error; } + } else { + throw error; } } } +} - private static async _handleLinkExistErrorAsync( - linkFn: () => Promise, - options: IInternalFileSystemCreateLinkOptions, - error: Error - ): Promise { - switch (options.alreadyExistsBehavior) { - case AlreadyExistsBehavior.Ignore: - break; - case AlreadyExistsBehavior.Overwrite: - // fsx.linkSync does not allow overwriting so we must manually delete. If it's - // a folder, it will throw an error. - await this.deleteFileAsync(options.newLinkPath); - await linkFn(); - break; - case AlreadyExistsBehavior.Error: - default: - throw error; - } +async function _handleLinkExistErrorAsync( + linkFn: () => Promise, + options: IInternalFileSystemCreateLinkOptions, + error: Error +): Promise { + switch (options.alreadyExistsBehavior) { + case AlreadyExistsBehavior.Ignore: + break; + case AlreadyExistsBehavior.Overwrite: + // fsx.linkSync does not allow overwriting so we must manually delete. If it's + // a folder, it will throw an error. + await FileSystem.deleteFileAsync(options.newLinkPath); + await linkFn(); + break; + case AlreadyExistsBehavior.Error: + default: + throw error; } +} - private static async _handleLinkAsync( - linkFn: () => Promise, - options: IInternalFileSystemCreateLinkOptions - ): Promise { - try { - await linkFn(); - } catch (error) { - if (FileSystem.isExistError(error as Error)) { - // Link exists, handle it - await FileSystem._handleLinkExistErrorAsync(linkFn, options, error as Error); - } else { - // When attempting to create a link in a directory that does not exist, an ENOENT - // or ENOTDIR error is thrown, so we should ensure the directory exists before - // retrying. There are also cases where the target file must exist, so validate in - // those cases to avoid confusing the missing directory with the missing target file. - if ( - FileSystem.isNotExistError(error as Error) && - (!options.linkTargetMustExist || (await FileSystem.existsAsync(options.linkTargetPath))) - ) { - await this.ensureFolderAsync(nodeJsPath.dirname(options.newLinkPath)); - try { - await linkFn(); - } catch (retryError) { - if (FileSystem.isExistError(retryError as Error)) { - // Another concurrent process may have created the link between the ensureFolderAsync - // call and the retry; handle it the same way as the initial exist error. - await FileSystem._handleLinkExistErrorAsync(linkFn, options, retryError as Error); - } else { - throw retryError; - } +async function _handleLinkAsync( + linkFn: () => Promise, + options: IInternalFileSystemCreateLinkOptions +): Promise { + try { + await linkFn(); + } catch (error) { + if (FileSystem.isExistError(error as Error)) { + // Link exists, handle it + await _handleLinkExistErrorAsync(linkFn, options, error as Error); + } else { + // When attempting to create a link in a directory that does not exist, an ENOENT + // or ENOTDIR error is thrown, so we should ensure the directory exists before + // retrying. There are also cases where the target file must exist, so validate in + // those cases to avoid confusing the missing directory with the missing target file. + if ( + FileSystem.isNotExistError(error as Error) && + (!options.linkTargetMustExist || (await FileSystem.existsAsync(options.linkTargetPath))) + ) { + await FileSystem.ensureFolderAsync(nodeJsPath.dirname(options.newLinkPath)); + try { + await linkFn(); + } catch (retryError) { + if (FileSystem.isExistError(retryError as Error)) { + // Another concurrent process may have created the link between the ensureFolderAsync + // call and the retry; handle it the same way as the initial exist error. + await _handleLinkExistErrorAsync(linkFn, options, retryError as Error); + } else { + throw retryError; } - } else { - throw error; } + } else { + throw error; } } } +} - private static _wrapException(fn: () => TResult): TResult { - try { - return fn(); - } catch (error) { - FileSystem._updateErrorMessage(error as Error); - throw error; - } +function _wrapException(fn: () => TResult): TResult { + try { + return fn(); + } catch (error) { + _updateErrorMessage(error as Error); + throw error; } +} - private static async _wrapExceptionAsync(fn: () => Promise): Promise { - try { - return await fn(); - } catch (error) { - FileSystem._updateErrorMessage(error as Error); - throw error; - } +async function _wrapExceptionAsync(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + _updateErrorMessage(error as Error); + throw error; } +} - private static _updateErrorMessage(error: Error): void { - if (FileSystem.isErrnoException(error)) { - if (FileSystem.isFileDoesNotExistError(error)) { - error.message = `File does not exist: ${error.path}\n${error.message}`; - } else if (FileSystem.isFolderDoesNotExistError(error)) { - error.message = `Folder does not exist: ${error.path}\n${error.message}`; - } else if (FileSystem.isExistError(error)) { - // Oddly, the typing does not include the `dest` property even though the documentation - // indicates it is there: https://nodejs.org/docs/latest-v10.x/api/errors.html#errors_error_dest - const extendedError: NodeJS.ErrnoException & { dest?: string } = error; - error.message = `File or folder already exists: ${extendedError.dest}\n${error.message}`; - } else if (FileSystem.isUnlinkNotPermittedError(error)) { - error.message = `File or folder could not be deleted: ${error.path}\n${error.message}`; - } else if (FileSystem.isDirectoryError(error)) { - error.message = `Target is a folder, not a file: ${error.path}\n${error.message}`; - } else if (FileSystem.isNotDirectoryError(error)) { - error.message = `Target is not a folder: ${error.path}\n${error.message}`; - } +function _updateErrorMessage(error: Error): void { + if (FileSystem.isErrnoException(error)) { + if (FileSystem.isFileDoesNotExistError(error)) { + error.message = `File does not exist: ${error.path}\n${error.message}`; + } else if (FileSystem.isFolderDoesNotExistError(error)) { + error.message = `Folder does not exist: ${error.path}\n${error.message}`; + } else if (FileSystem.isExistError(error)) { + // Oddly, the typing does not include the `dest` property even though the documentation + // indicates it is there: https://nodejs.org/docs/latest-v10.x/api/errors.html#errors_error_dest + const extendedError: NodeJS.ErrnoException & { dest?: string } = error; + error.message = `File or folder already exists: ${extendedError.dest}\n${error.message}`; + } else if (FileSystem.isUnlinkNotPermittedError(error)) { + error.message = `File or folder could not be deleted: ${error.path}\n${error.message}`; + } else if (FileSystem.isDirectoryError(error)) { + error.message = `Target is a folder, not a file: ${error.path}\n${error.message}`; + } else if (FileSystem.isNotDirectoryError(error)) { + error.message = `Target is not a folder: ${error.path}\n${error.message}`; } } } diff --git a/libraries/node-core-library/src/Import.ts b/libraries/node-core-library/src/Import.ts index c794fc8500f..029fbe104e4 100644 --- a/libraries/node-core-library/src/Import.ts +++ b/libraries/node-core-library/src/Import.ts @@ -149,20 +149,20 @@ interface IPackageDescriptor { packageName: string; } +let _builtInModules: Set | undefined; +function _getBuiltInModules(): Set { + if (!_builtInModules) { + _builtInModules = new Set(nodeModule.builtinModules); + } + + return _builtInModules; +} + /** * Helpers for resolving and importing Node.js modules. * @public */ export class Import { - private static __builtInModules: Set | undefined; - private static get _builtInModules(): Set { - if (!Import.__builtInModules) { - Import.__builtInModules = new Set(nodeModule.builtinModules); - } - - return Import.__builtInModules; - } - /** * Provides a way to improve process startup times by lazy-loading imported modules. * @@ -281,12 +281,12 @@ export class Import { // against the first path segment const slashIndex: number = modulePath.indexOf('/'); const moduleName: string = slashIndex === -1 ? modulePath : modulePath.slice(0, slashIndex); - if (!includeSystemModules && Import._builtInModules.has(moduleName)) { + if (!includeSystemModules && _getBuiltInModules().has(moduleName)) { throw new Error(`Cannot find module "${modulePath}" from "${options.baseFolderPath}".`); } if (allowSelfReference === true) { - const ownPackage: IPackageDescriptor | undefined = Import._getPackageName(normalizedRootPath); + const ownPackage: IPackageDescriptor | undefined = _getPackageName(normalizedRootPath); if ( ownPackage && (modulePath === ownPackage.packageName || modulePath.startsWith(`${ownPackage.packageName}/`)) @@ -337,12 +337,12 @@ export class Import { // against the first path segment const slashIndex: number = modulePath.indexOf('/'); const moduleName: string = slashIndex === -1 ? modulePath : modulePath.slice(0, slashIndex); - if (!includeSystemModules && Import._builtInModules.has(moduleName)) { + if (!includeSystemModules && _getBuiltInModules().has(moduleName)) { throw new Error(`Cannot find module "${modulePath}" from "${options.baseFolderPath}".`); } if (allowSelfReference === true) { - const ownPackage: IPackageDescriptor | undefined = Import._getPackageName(normalizedRootPath); + const ownPackage: IPackageDescriptor | undefined = _getPackageName(normalizedRootPath); if ( ownPackage && (modulePath === ownPackage.packageName || modulePath.startsWith(`${ownPackage.packageName}/`)) @@ -428,14 +428,14 @@ export class Import { useNodeJSResolver } = options; - if (includeSystemModules && Import._builtInModules.has(packageName)) { + if (includeSystemModules && _getBuiltInModules().has(packageName)) { return packageName; } const normalizedRootPath: string = (getRealPath || FileSystem.getRealPath)(baseFolderPath); if (allowSelfReference) { - const ownPackage: IPackageDescriptor | undefined = Import._getPackageName(normalizedRootPath); + const ownPackage: IPackageDescriptor | undefined = _getPackageName(normalizedRootPath); if (ownPackage && ownPackage.packageName === packageName) { return ownPackage.packageRootPath; } @@ -476,7 +476,7 @@ export class Import { getRealPathAsync } = options; - if (includeSystemModules && Import._builtInModules.has(packageName)) { + if (includeSystemModules && _getBuiltInModules().has(packageName)) { return packageName; } @@ -485,7 +485,7 @@ export class Import { ); if (allowSelfReference) { - const ownPackage: IPackageDescriptor | undefined = Import._getPackageName(normalizedRootPath); + const ownPackage: IPackageDescriptor | undefined = _getPackageName(normalizedRootPath); if (ownPackage && ownPackage.packageName === packageName) { return ownPackage.packageRootPath; } @@ -543,18 +543,18 @@ export class Import { throw new Error(`Cannot find package "${packageName}" from "${baseFolderPath}": ${e}`); } } +} - private static _getPackageName(rootPath: string): IPackageDescriptor | undefined { - const packageJsonPath: string | undefined = - PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(rootPath); - if (packageJsonPath) { - const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonPath); - return { - packageRootPath: path.dirname(packageJsonPath), - packageName: packageJson.name - }; - } else { - return undefined; - } +function _getPackageName(rootPath: string): IPackageDescriptor | undefined { + const packageJsonPath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(rootPath); + if (packageJsonPath) { + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonPath); + return { + packageRootPath: path.dirname(packageJsonPath), + packageName: packageJson.name + }; + } else { + return undefined; } } diff --git a/libraries/node-core-library/src/InternalError.ts b/libraries/node-core-library/src/InternalError.ts index 26b5b2b1e74..84ba4d03621 100644 --- a/libraries/node-core-library/src/InternalError.ts +++ b/libraries/node-core-library/src/InternalError.ts @@ -34,7 +34,7 @@ export class InternalError extends Error { * explaining that the user has encountered a software defect. */ public constructor(message: string) { - super(InternalError._formatMessage(message)); + super(_formatMessage(message)); // Manually set the prototype, as we can no longer extend built-in classes like Error, Array, Map, etc. // https://github.com/microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work @@ -50,14 +50,14 @@ export class InternalError extends Error { } } - private static _formatMessage(unformattedMessage: string): string { - return ( - `Internal Error: ${unformattedMessage}\n\nYou have encountered a software defect. Please consider` + - ` reporting the issue to the maintainers of this application.` - ); - } - public override toString(): string { return this.message; // Avoid adding the "Error:" prefix } } + +function _formatMessage(unformattedMessage: string): string { + return ( + `Internal Error: ${unformattedMessage}\n\nYou have encountered a software defect. Please consider` + + ` reporting the issue to the maintainers of this application.` + ); +} diff --git a/libraries/node-core-library/src/JsonFile.ts b/libraries/node-core-library/src/JsonFile.ts index 21f55b6fc2c..151edd6d366 100644 --- a/libraries/node-core-library/src/JsonFile.ts +++ b/libraries/node-core-library/src/JsonFile.ts @@ -215,7 +215,7 @@ export class JsonFile { public static load(jsonFilename: string, options?: IJsonFileParseOptions): JsonObject { try { const contents: string = FileSystem.readFile(jsonFilename); - const parseOptions: jju.ParseOptions = JsonFile._buildJjuParseOptions(options); + const parseOptions: jju.ParseOptions = _buildJjuParseOptions(options); return jju.parse(contents, parseOptions); } catch (error) { if (FileSystem.isNotExistError(error as Error)) { @@ -236,7 +236,7 @@ export class JsonFile { public static async loadAsync(jsonFilename: string, options?: IJsonFileParseOptions): Promise { try { const contents: string = await FileSystem.readFileAsync(jsonFilename); - const parseOptions: jju.ParseOptions = JsonFile._buildJjuParseOptions(options); + const parseOptions: jju.ParseOptions = _buildJjuParseOptions(options); return jju.parse(contents, parseOptions); } catch (error) { if (FileSystem.isNotExistError(error as Error)) { @@ -255,7 +255,7 @@ export class JsonFile { * Parses a JSON file's contents. */ public static parseString(jsonContents: string, options?: IJsonFileParseOptions): JsonObject { - const parseOptions: jju.ParseOptions = JsonFile._buildJjuParseOptions(options); + const parseOptions: jju.ParseOptions = _buildJjuParseOptions(options); return jju.parse(jsonContents, parseOptions); } @@ -374,13 +374,13 @@ export class JsonFile { }); if (options.headerComment !== undefined) { - stringified = JsonFile._formatJsonHeaderComment(options.headerComment) + stringified; + stringified = _formatJsonHeaderComment(options.headerComment) + stringified; } } else { stringified = JSON.stringify(newJsonObject, undefined, 2); if (options.headerComment !== undefined) { - stringified = JsonFile._formatJsonHeaderComment(options.headerComment) + stringified; + stringified = _formatJsonHeaderComment(options.headerComment) + stringified; } } @@ -511,97 +511,97 @@ export class JsonFile { * are any undefined members. */ public static validateNoUndefinedMembers(jsonObject: JsonObject): void { - return JsonFile._validateNoUndefinedMembers(jsonObject, []); + return _validateNoUndefinedMembers(jsonObject, []); } +} - // Private implementation of validateNoUndefinedMembers() - private static _validateNoUndefinedMembers(jsonObject: JsonObject, keyPath: string[]): void { - if (!jsonObject) { - return; - } - if (typeof jsonObject === 'object') { - for (const key of Object.keys(jsonObject)) { - keyPath.push(key); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const value: any = jsonObject[key]; - if (value === undefined) { - const fullPath: string = JsonFile._formatKeyPath(keyPath); - throw new Error(`The value for ${fullPath} is "undefined" and cannot be serialized as JSON`); - } - - JsonFile._validateNoUndefinedMembers(value, keyPath); - keyPath.pop(); - } - } +// Private implementation of validateNoUndefinedMembers() +function _validateNoUndefinedMembers(jsonObject: JsonObject, keyPath: string[]): void { + if (!jsonObject) { + return; } - - // Given this input: ['items', '4', 'syntax', 'parameters', 'string "with" symbols", 'type'] - // Return this string: items[4].syntax.parameters["string \"with\" symbols"].type - private static _formatKeyPath(keyPath: string[]): string { - let result: string = ''; - - for (const key of keyPath) { - if (/^[0-9]+$/.test(key)) { - // It's an integer, so display like this: parent[123] - result += `[${key}]`; - } else if (/^[a-z_][a-z_0-9]*$/i.test(key)) { - // It's an alphanumeric identifier, so display like this: parent.name - if (result) { - result += '.'; - } - result += `${key}`; - } else { - // It's a freeform string, so display like this: parent["A path: \"C:\\file\""] - - // Convert this: A path: "C:\file" - // To this: A path: \"C:\\file\" - const escapedKey: string = key - .replace(/[\\]/g, '\\\\') // escape backslashes - .replace(/["]/g, '\\'); // escape quotes - result += `["${escapedKey}"]`; + if (typeof jsonObject === 'object') { + for (const key of Object.keys(jsonObject)) { + keyPath.push(key); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const value: any = jsonObject[key]; + if (value === undefined) { + const fullPath: string = _formatKeyPath(keyPath); + throw new Error(`The value for ${fullPath} is "undefined" and cannot be serialized as JSON`); } + + _validateNoUndefinedMembers(value, keyPath); + keyPath.pop(); } - return result; } +} - private static _formatJsonHeaderComment(headerComment: string): string { - if (headerComment === '') { - return ''; - } - const lines: string[] = headerComment.split('\n'); - const result: string[] = []; - for (const line of lines) { - if (!/^\s*$/.test(line) && !/^\s*\/\//.test(line)) { - throw new Error( - 'The headerComment lines must be blank or start with the "//" prefix.\n' + - 'Invalid line' + - JSON.stringify(line) - ); +// Given this input: ['items', '4', 'syntax', 'parameters', 'string "with" symbols", 'type'] +// Return this string: items[4].syntax.parameters["string \"with\" symbols"].type +function _formatKeyPath(keyPath: string[]): string { + let result: string = ''; + + for (const key of keyPath) { + if (/^[0-9]+$/.test(key)) { + // It's an integer, so display like this: parent[123] + result += `[${key}]`; + } else if (/^[a-z_][a-z_0-9]*$/i.test(key)) { + // It's an alphanumeric identifier, so display like this: parent.name + if (result) { + result += '.'; } - result.push(Text.replaceAll(line, '\r', '')); + result += `${key}`; + } else { + // It's a freeform string, so display like this: parent["A path: \"C:\\file\""] + + // Convert this: A path: "C:\file" + // To this: A path: \"C:\\file\" + const escapedKey: string = key + .replace(/[\\]/g, '\\\\') // escape backslashes + .replace(/["]/g, '\\'); // escape quotes + result += `["${escapedKey}"]`; } - return lines.join('\n') + '\n'; } + return result; +} - private static _buildJjuParseOptions(options: IJsonFileParseOptions = {}): jju.ParseOptions { - const parseOptions: jju.ParseOptions = { - reserved_keys: 'replace' - }; - - switch (options.jsonSyntax) { - case JsonSyntax.Strict: - parseOptions.mode = 'json'; - break; - case JsonSyntax.JsonWithComments: - parseOptions.mode = 'cjson'; - break; - case JsonSyntax.Json5: - default: - parseOptions.mode = 'json5'; - break; +function _formatJsonHeaderComment(headerComment: string): string { + if (headerComment === '') { + return ''; + } + const lines: string[] = headerComment.split('\n'); + const result: string[] = []; + for (const line of lines) { + if (!/^\s*$/.test(line) && !/^\s*\/\//.test(line)) { + throw new Error( + 'The headerComment lines must be blank or start with the "//" prefix.\n' + + 'Invalid line' + + JSON.stringify(line) + ); } + result.push(Text.replaceAll(line, '\r', '')); + } + return lines.join('\n') + '\n'; +} - return parseOptions; +function _buildJjuParseOptions(options: IJsonFileParseOptions = {}): jju.ParseOptions { + const parseOptions: jju.ParseOptions = { + reserved_keys: 'replace' + }; + + switch (options.jsonSyntax) { + case JsonSyntax.Strict: + parseOptions.mode = 'json'; + break; + case JsonSyntax.JsonWithComments: + parseOptions.mode = 'cjson'; + break; + case JsonSyntax.Json5: + default: + parseOptions.mode = 'json5'; + break; } + + return parseOptions; } diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index a1ffdf13dfb..8864740ef41 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -259,72 +259,6 @@ export class JsonSchema { return schema; } - private static _collectDependentSchemas( - collectedSchemas: JsonSchema[], - dependentSchemas: JsonSchema[], - seenObjects: Set, - seenIds: Set - ): void { - for (const dependentSchema of dependentSchemas) { - // It's okay for the same schema to appear multiple times in the tree, but we only process it once - if (seenObjects.has(dependentSchema)) { - continue; - } - seenObjects.add(dependentSchema); - - const schemaId: string = dependentSchema._ensureLoaded(); - if (schemaId === '') { - throw new Error( - `This schema ${dependentSchema.shortName} cannot be referenced` + - ' because is missing the "id" (draft-04) or "$id" field' - ); - } - if (seenIds.has(schemaId)) { - throw new Error( - `This schema ${dependentSchema.shortName} has the same "id" (draft-04) or "$id" as another schema in this set` - ); - } - - seenIds.add(schemaId); - - collectedSchemas.push(dependentSchema); - - JsonSchema._collectDependentSchemas( - collectedSchemas, - dependentSchema._dependentSchemas, - seenObjects, - seenIds - ); - } - } - - /** - * Used to nicely format the ZSchema error tree. - */ - private static _formatErrorDetails(errorDetails: ErrorObject[]): string { - return JsonSchema._formatErrorDetailsHelper(errorDetails, '', ''); - } - - /** - * Used by _formatErrorDetails. - */ - private static _formatErrorDetailsHelper( - errorDetails: ErrorObject[], - indent: string, - buffer: string - ): string { - for (const errorDetail of errorDetails) { - buffer += os.EOL + indent + `Error: #${errorDetail.instancePath}`; - - buffer += os.EOL + indent + ` ${errorDetail.message}`; - if (errorDetail.params?.additionalProperty) { - buffer += `: ${errorDetail.params?.additionalProperty}`; - } - } - - return buffer; - } - /** * Returns a short name for this schema, for use in error messages. * @remarks @@ -391,7 +325,7 @@ export class JsonSchema { const seenObjects: Set = new Set(); const seenIds: Set = new Set(); - JsonSchema._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); + this._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); // Unless explicitly rejected, scan the top-level keys of each schema for vendor // extension keys matching the x-- pattern and register them with @@ -415,7 +349,7 @@ export class JsonSchema { throw new Error( `Failed to validate schema "${collectedSchema.shortName}":` + os.EOL + - JsonSchema._formatErrorDetails(validator.errors) + _formatErrorDetails(validator.errors) ); } validator.addSchema(collectedSchema._schemaObject); @@ -470,7 +404,7 @@ export class JsonSchema { } if (this._validator && !this._validator(jsonObject)) { - const errorDetails: string = JsonSchema._formatErrorDetails(this._validator.errors!); + const errorDetails: string = _formatErrorDetails(this._validator.errors!); const args: IJsonSchemaErrorInfo = { details: errorDetails @@ -485,4 +419,66 @@ export class JsonSchema { } return (this._schemaObject as ISchemaWithId).id || (this._schemaObject as ISchemaWithId).$id || ''; } + + private _collectDependentSchemas( + collectedSchemas: JsonSchema[], + dependentSchemas: JsonSchema[], + seenObjects: Set, + seenIds: Set + ): void { + for (const dependentSchema of dependentSchemas) { + // It's okay for the same schema to appear multiple times in the tree, but we only process it once + if (seenObjects.has(dependentSchema)) { + continue; + } + seenObjects.add(dependentSchema); + + const schemaId: string = dependentSchema._ensureLoaded(); + if (schemaId === '') { + throw new Error( + `This schema ${dependentSchema.shortName} cannot be referenced` + + ' because is missing the "id" (draft-04) or "$id" field' + ); + } + if (seenIds.has(schemaId)) { + throw new Error( + `This schema ${dependentSchema.shortName} has the same "id" (draft-04) or "$id" as another schema in this set` + ); + } + + seenIds.add(schemaId); + + collectedSchemas.push(dependentSchema); + + this._collectDependentSchemas( + collectedSchemas, + dependentSchema._dependentSchemas, + seenObjects, + seenIds + ); + } + } +} + +/** + * Used to nicely format the ZSchema error tree. + */ +function _formatErrorDetails(errorDetails: ErrorObject[]): string { + return _formatErrorDetailsHelper(errorDetails, '', ''); +} + +/** + * Used by _formatErrorDetails. + */ +function _formatErrorDetailsHelper(errorDetails: ErrorObject[], indent: string, buffer: string): string { + for (const errorDetail of errorDetails) { + buffer += os.EOL + indent + `Error: #${errorDetail.instancePath}`; + + buffer += os.EOL + indent + ` ${errorDetail.message}`; + if (errorDetail.params?.additionalProperty) { + buffer += `: ${errorDetail.params?.additionalProperty}`; + } + } + + return buffer; } diff --git a/libraries/node-core-library/src/LockFile.ts b/libraries/node-core-library/src/LockFile.ts index da2a4f468b3..511a5e86490 100644 --- a/libraries/node-core-library/src/LockFile.ts +++ b/libraries/node-core-library/src/LockFile.ts @@ -141,6 +141,23 @@ export function getProcessStartTime(pid: number): string | undefined { // multiple locks are acquired in the same process. const IN_PROC_LOCKS: Set = new Set(); +// The function used to determine a process's start time. Overridable for unit testing. +let _getStartTime: (pid: number) => string | undefined = getProcessStartTime; + +/** + * For unit testing only: overrides the function used to determine a process's start time. + * @internal + */ +export function _setLockFileGetProcessStartTime(fn: (pid: number) => string | undefined): void { + _getStartTime = fn; +} + +interface ITryAcquireResult { + fileWriter: FileWriter | undefined; + filePath: string; + dirtyWhenAcquired: boolean; +} + /** * The `LockFile` implements a file-based mutex for synchronizing access to a shared resource * between multiple Node.js processes. It is not recommended for synchronization solely within @@ -152,8 +169,6 @@ const IN_PROC_LOCKS: Set = new Set(); * @public */ export class LockFile { - private static _getStartTime: (pid: number) => string | undefined = getProcessStartTime; - private _fileWriter: FileWriter | undefined; private _filePath: string; private _dirtyWhenAcquired: boolean; @@ -211,7 +226,12 @@ export class LockFile { public static tryAcquire(resourceFolder: string, resourceName: string): LockFile | undefined { FileSystem.ensureFolder(resourceFolder); const lockFilePath: string = LockFile.getLockFilePath(resourceFolder, resourceName); - return LockFile._tryAcquireInner(resourceFolder, resourceName, lockFilePath); + const result: ITryAcquireResult | undefined = _tryAcquireInner( + resourceFolder, + resourceName, + lockFilePath + ); + return result && new LockFile(result.fileWriter, result.filePath, result.dirtyWhenAcquired); } /** @@ -250,13 +270,13 @@ export class LockFile { // eslint-disable-next-line no-unmodified-loop-condition while (!timeoutTime || Date.now() <= timeoutTime) { - const lock: LockFile | undefined = LockFile._tryAcquireInner( + const result: ITryAcquireResult | undefined = _tryAcquireInner( resourceFolder, resourceName, lockFilePath ); - if (lock) { - return lock; + if (result) { + return new LockFile(result.fileWriter, result.filePath, result.dirtyWhenAcquired); } await Async.sleepAsync(interval); @@ -265,246 +285,6 @@ export class LockFile { throw new Error(`Exceeded maximum wait time to acquire lock for resource "${resourceName}"`); } - private static _tryAcquireInner( - resourceFolder: string, - resourceName: string, - lockFilePath: string - ): LockFile | undefined { - if (!IN_PROC_LOCKS.has(lockFilePath)) { - switch (process.platform) { - case 'win32': { - return LockFile._tryAcquireWindows(lockFilePath); - } - - case 'linux': - case 'darwin': { - return LockFile._tryAcquireMacOrLinux(resourceFolder, resourceName, lockFilePath); - } - - default: { - throw new Error(`File locking not implemented for platform: "${process.platform}"`); - } - } - } - } - - /** - * Attempts to acquire the lock on a Linux or OSX machine - */ - private static _tryAcquireMacOrLinux( - resourceFolder: string, - resourceName: string, - pidLockFilePath: string - ): LockFile | undefined { - // get the current process identifier (PID) - const pid: number = process.pid; - - // Suppose that a process terminates unexpectedly without deleting its PID-based lockfile, - // then we check to see if the process is still alive. The OS may have given the same PID - // to a new process, how to detect that? We will rely on getProcessStartTime() which - // is stored in the file itself for comparison. - const startTime: string | undefined = LockFile._getStartTime(pid); - - if (!startTime) { - throw new Error(`Unable to calculate start time for current process.`); - } - - let lockFileHandle: FileWriter | undefined; - - let lockFile: LockFile; - - try { - // open in write mode since if this file exists, it cannot be from the current process - // TODO: This will malfunction if the same process tries to acquire two locks on the same file. - // We should ideally maintain a dictionary of normalized acquired filenames - lockFileHandle = FileWriter.open(pidLockFilePath); - lockFileHandle.write(startTime); - const currentBirthTimeMs: number = lockFileHandle.getStatistics().birthtime.getTime(); - - let smallestBirthTimeMs: number = currentBirthTimeMs; - let smallestBirthTimePid: string = pid.toString(); - - // now, scan the directory for all lockfiles - const files: string[] = FileSystem.readFolderItemNames(resourceFolder); - - // look for anything ending with # then numbers and ".lock" - const lockFileRegExp: RegExp = /^(.+)#([0-9]+)\.lock$/; - - // If we are the process to acquire the lock, it becomes our responsibility to clean up these - // stale files. If there is at least 1 stale file, then the resource is assumed to be "dirty" - // (for example, the previous process was interrupted before releasing or while acquiring). - const staleFilesToDelete: string[] = []; - - let match: RegExpMatchArray | null; - let otherPid: string; - for (const fileInFolder of files) { - if ( - (match = fileInFolder.match(lockFileRegExp)) && - match[1] === resourceName && - (otherPid = match[2]) !== pid.toString() - ) { - // We found at least one lockfile hanging around that isn't ours - const fileInFolderPath: string = `${resourceFolder}/${fileInFolder}`; - - // console.log(`FOUND OTHER LOCKFILE: ${otherPid}`); - - // Actual start time of the other PID - const otherPidCurrentStartTime: string | undefined = LockFile._getStartTime(parseInt(otherPid, 10)); - - // The start time from the file, which we will compare with otherPidCurrentStartTime - // to determine whether the PID got reused by a new process. - let otherPidOldStartTime: string | undefined; - let otherBirthtimeMs: number | undefined; - try { - otherPidOldStartTime = FileSystem.readFile(fileInFolderPath); - // check the timestamp of the file - otherBirthtimeMs = FileSystem.getStatistics(fileInFolderPath).birthtime.getTime(); - } catch (error) { - if (FileSystem.isNotExistError(error)) { - // ==> Properly closed lockfile, safe to ignore: - // The other process deleted the file, which we assume means it completed successfully, - // so the state is not dirty. This is equivalent to if readFolderItemNames() never saw - // the file in the firstplace. - continue; - } - } - - // What the other process's file exists, but it is an empty file? - // Either they were terminated while acquiring, or else they haven't finished writing it yet. - if (otherBirthtimeMs !== undefined && otherPidOldStartTime === '') { - if (otherBirthtimeMs > currentBirthTimeMs) { - // ==> Safe to ignore - // If the other process was terminated, it happened before they finished acquiring. - // If the other process is alive, their file is newer, so we will acquire instead of them. - - // console.log(`Ignoring lock for pid ${otherPid} because its lockfile is newer than ours.`); - continue; - } else if ( - otherBirthtimeMs - currentBirthTimeMs < 0 && - otherBirthtimeMs - currentBirthTimeMs > -1000 - ) { - // ==> Race condition - // The other process created their file first, so they will probably acquire the lock - // after they finish writing the contents. But what if their process is actually dead - // and replaced by a new process with the same PID? Normally the otherPidOldStartTime - // gives the answer, but in this edge case we are missing that information. - // So we conservatively assume that it should not take them more than 1000ms to - // open a file, write a PID, and close the file. - return undefined; // fail to acquire and retry later - } - } - - // console.log(`Other pid ${otherPid} lockfile has start time: "${otherPidOldStartTime}"`); - // console.log(`Other pid ${otherPid} actually has start time: "${otherPidCurrentStartTime}"`); - - // Time to compare - if (!otherPidCurrentStartTime || otherPidOldStartTime !== otherPidCurrentStartTime) { - // ==> Stale lockfile - // This file doesn't prevent us from acquiring the lock, but it does indicate that - // the resource was left in a dirty state. (If we delete the file right now, that - // information would be lost, so we clean up later when we acquire successfully.) - - // console.log(`Other pid ${otherPid} is no longer executing!`); - staleFilesToDelete.push(fileInFolderPath); - continue; - } - - // console.log(`Pid ${otherPid} lockfile has birth time: ${otherBirthtimeMs}`); - // console.log(`Pid ${pid} lockfile has birth time: ${currentBirthTimeMs}`); - - if (otherBirthtimeMs !== undefined) { - // ==> We found a valid file belonging to another process. - // With multiple parties trying to acquire, the winner is the smallestBirthTime, - // so we need to sort. - - // the other lock file was created before the current earliest lock file - // or the other lock file was created at the same exact time, but has earlier pid - - // note that it is acceptable to do a direct comparison of the PIDs in this case - // since we are establishing a consistent order to apply to the lock files in all - // execution instances. - - // it doesn't matter that the PIDs roll over, we've already - // established that these processes all started at the same time, so we just - // need to get all instances of the lock test to agree which one won. - if ( - otherBirthtimeMs < smallestBirthTimeMs || - (otherBirthtimeMs === smallestBirthTimeMs && otherPid < smallestBirthTimePid) - ) { - smallestBirthTimeMs = otherBirthtimeMs; - smallestBirthTimePid = otherPid; - } - } - } - } - - if (smallestBirthTimePid !== pid.toString()) { - // we do not have the lock - return undefined; - } - - let dirtyWhenAcquired: boolean = false; - for (const staleFileToDelete of staleFilesToDelete) { - FileSystem.deleteFile(staleFileToDelete, { throwIfNotExists: false }); - dirtyWhenAcquired = true; - } - - // We have the lock! - lockFile = new LockFile(lockFileHandle, pidLockFilePath, dirtyWhenAcquired); - lockFileHandle = undefined; // The LockFile object has taken ownership of our handle - } finally { - if (lockFileHandle) { - // ensure our lock is closed - lockFileHandle.close(); - FileSystem.deleteFile(pidLockFilePath); - } - } - return lockFile; - } - - /** - * Attempts to acquire the lock using Windows - * This algorithm is much simpler since we can rely on the operating system - */ - private static _tryAcquireWindows(lockFilePath: string): LockFile | undefined { - let dirtyWhenAcquired: boolean = false; - - let fileHandle: FileWriter | undefined; - let lockFile: LockFile; - - try { - if (FileSystem.exists(lockFilePath)) { - dirtyWhenAcquired = true; - - // If the lockfile is held by an process with an exclusive lock, then removing it will - // silently fail. OpenSync() below will then fail and we will be unable to create a lock. - - // Otherwise, the lockfile is sitting on disk, but nothing is holding it, implying that - // the last process to hold it died. - FileSystem.deleteFile(lockFilePath); - } - - try { - // Attempt to open an exclusive lockfile - fileHandle = FileWriter.open(lockFilePath, { exclusive: true }); - } catch (error) { - // we tried to delete the lock, but something else is holding it, - // (probably an active process), therefore we are unable to create a lock - return undefined; - } - - // Ensure we can hand off the file descriptor to the lockfile - lockFile = new LockFile(fileHandle, lockFilePath, dirtyWhenAcquired); - fileHandle = undefined; - } finally { - if (fileHandle) { - fileHandle.close(); - } - } - - return lockFile; - } - /** * Unlocks a file and optionally removes it from disk. * This can only be called once. @@ -548,3 +328,243 @@ export class LockFile { return this._fileWriter === undefined; } } + +function _tryAcquireInner( + resourceFolder: string, + resourceName: string, + lockFilePath: string +): ITryAcquireResult | undefined { + if (!IN_PROC_LOCKS.has(lockFilePath)) { + switch (process.platform) { + case 'win32': { + return _tryAcquireWindows(lockFilePath); + } + + case 'linux': + case 'darwin': { + return _tryAcquireMacOrLinux(resourceFolder, resourceName, lockFilePath); + } + + default: { + throw new Error(`File locking not implemented for platform: "${process.platform}"`); + } + } + } +} + +/** + * Attempts to acquire the lock on a Linux or OSX machine + */ +function _tryAcquireMacOrLinux( + resourceFolder: string, + resourceName: string, + pidLockFilePath: string +): ITryAcquireResult | undefined { + // get the current process identifier (PID) + const pid: number = process.pid; + + // Suppose that a process terminates unexpectedly without deleting its PID-based lockfile, + // then we check to see if the process is still alive. The OS may have given the same PID + // to a new process, how to detect that? We will rely on getProcessStartTime() which + // is stored in the file itself for comparison. + const startTime: string | undefined = _getStartTime(pid); + + if (!startTime) { + throw new Error(`Unable to calculate start time for current process.`); + } + + let lockFileHandle: FileWriter | undefined; + + let result: ITryAcquireResult | undefined; + + try { + // open in write mode since if this file exists, it cannot be from the current process + // TODO: This will malfunction if the same process tries to acquire two locks on the same file. + // We should ideally maintain a dictionary of normalized acquired filenames + lockFileHandle = FileWriter.open(pidLockFilePath); + lockFileHandle.write(startTime); + const currentBirthTimeMs: number = lockFileHandle.getStatistics().birthtime.getTime(); + + let smallestBirthTimeMs: number = currentBirthTimeMs; + let smallestBirthTimePid: string = pid.toString(); + + // now, scan the directory for all lockfiles + const files: string[] = FileSystem.readFolderItemNames(resourceFolder); + + // look for anything ending with # then numbers and ".lock" + const lockFileRegExp: RegExp = /^(.+)#([0-9]+)\.lock$/; + + // If we are the process to acquire the lock, it becomes our responsibility to clean up these + // stale files. If there is at least 1 stale file, then the resource is assumed to be "dirty" + // (for example, the previous process was interrupted before releasing or while acquiring). + const staleFilesToDelete: string[] = []; + + let match: RegExpMatchArray | null; + let otherPid: string; + for (const fileInFolder of files) { + if ( + (match = fileInFolder.match(lockFileRegExp)) && + match[1] === resourceName && + (otherPid = match[2]) !== pid.toString() + ) { + // We found at least one lockfile hanging around that isn't ours + const fileInFolderPath: string = `${resourceFolder}/${fileInFolder}`; + + // console.log(`FOUND OTHER LOCKFILE: ${otherPid}`); + + // Actual start time of the other PID + const otherPidCurrentStartTime: string | undefined = _getStartTime(parseInt(otherPid, 10)); + + // The start time from the file, which we will compare with otherPidCurrentStartTime + // to determine whether the PID got reused by a new process. + let otherPidOldStartTime: string | undefined; + let otherBirthtimeMs: number | undefined; + try { + otherPidOldStartTime = FileSystem.readFile(fileInFolderPath); + // check the timestamp of the file + otherBirthtimeMs = FileSystem.getStatistics(fileInFolderPath).birthtime.getTime(); + } catch (error) { + if (FileSystem.isNotExistError(error)) { + // ==> Properly closed lockfile, safe to ignore: + // The other process deleted the file, which we assume means it completed successfully, + // so the state is not dirty. This is equivalent to if readFolderItemNames() never saw + // the file in the firstplace. + continue; + } + } + + // What the other process's file exists, but it is an empty file? + // Either they were terminated while acquiring, or else they haven't finished writing it yet. + if (otherBirthtimeMs !== undefined && otherPidOldStartTime === '') { + if (otherBirthtimeMs > currentBirthTimeMs) { + // ==> Safe to ignore + // If the other process was terminated, it happened before they finished acquiring. + // If the other process is alive, their file is newer, so we will acquire instead of them. + + // console.log(`Ignoring lock for pid ${otherPid} because its lockfile is newer than ours.`); + continue; + } else if ( + otherBirthtimeMs - currentBirthTimeMs < 0 && + otherBirthtimeMs - currentBirthTimeMs > -1000 + ) { + // ==> Race condition + // The other process created their file first, so they will probably acquire the lock + // after they finish writing the contents. But what if their process is actually dead + // and replaced by a new process with the same PID? Normally the otherPidOldStartTime + // gives the answer, but in this edge case we are missing that information. + // So we conservatively assume that it should not take them more than 1000ms to + // open a file, write a PID, and close the file. + return undefined; // fail to acquire and retry later + } + } + + // console.log(`Other pid ${otherPid} lockfile has start time: "${otherPidOldStartTime}"`); + // console.log(`Other pid ${otherPid} actually has start time: "${otherPidCurrentStartTime}"`); + + // Time to compare + if (!otherPidCurrentStartTime || otherPidOldStartTime !== otherPidCurrentStartTime) { + // ==> Stale lockfile + // This file doesn't prevent us from acquiring the lock, but it does indicate that + // the resource was left in a dirty state. (If we delete the file right now, that + // information would be lost, so we clean up later when we acquire successfully.) + + // console.log(`Other pid ${otherPid} is no longer executing!`); + staleFilesToDelete.push(fileInFolderPath); + continue; + } + + // console.log(`Pid ${otherPid} lockfile has birth time: ${otherBirthtimeMs}`); + // console.log(`Pid ${pid} lockfile has birth time: ${currentBirthTimeMs}`); + + if (otherBirthtimeMs !== undefined) { + // ==> We found a valid file belonging to another process. + // With multiple parties trying to acquire, the winner is the smallestBirthTime, + // so we need to sort. + + // the other lock file was created before the current earliest lock file + // or the other lock file was created at the same exact time, but has earlier pid + + // note that it is acceptable to do a direct comparison of the PIDs in this case + // since we are establishing a consistent order to apply to the lock files in all + // execution instances. + + // it doesn't matter that the PIDs roll over, we've already + // established that these processes all started at the same time, so we just + // need to get all instances of the lock test to agree which one won. + if ( + otherBirthtimeMs < smallestBirthTimeMs || + (otherBirthtimeMs === smallestBirthTimeMs && otherPid < smallestBirthTimePid) + ) { + smallestBirthTimeMs = otherBirthtimeMs; + smallestBirthTimePid = otherPid; + } + } + } + } + + if (smallestBirthTimePid !== pid.toString()) { + // we do not have the lock + return undefined; + } + + let dirtyWhenAcquired: boolean = false; + for (const staleFileToDelete of staleFilesToDelete) { + FileSystem.deleteFile(staleFileToDelete, { throwIfNotExists: false }); + dirtyWhenAcquired = true; + } + + // We have the lock! + result = { fileWriter: lockFileHandle, filePath: pidLockFilePath, dirtyWhenAcquired }; + lockFileHandle = undefined; // The returned result has taken ownership of our handle + } finally { + if (lockFileHandle) { + // ensure our lock is closed + lockFileHandle.close(); + FileSystem.deleteFile(pidLockFilePath); + } + } + return result; +} + +/** + * Attempts to acquire the lock using Windows + * This algorithm is much simpler since we can rely on the operating system + */ +function _tryAcquireWindows(lockFilePath: string): ITryAcquireResult | undefined { + let dirtyWhenAcquired: boolean = false; + + let fileHandle: FileWriter | undefined; + let result: ITryAcquireResult | undefined; + + try { + if (FileSystem.exists(lockFilePath)) { + dirtyWhenAcquired = true; + + // If the lockfile is held by an process with an exclusive lock, then removing it will + // silently fail. OpenSync() below will then fail and we will be unable to create a lock. + + // Otherwise, the lockfile is sitting on disk, but nothing is holding it, implying that + // the last process to hold it died. + FileSystem.deleteFile(lockFilePath); + } + + try { + // Attempt to open an exclusive lockfile + fileHandle = FileWriter.open(lockFilePath, { exclusive: true }); + } catch (error) { + // we tried to delete the lock, but something else is holding it, + // (probably an active process), therefore we are unable to create a lock + return undefined; + } + + // Ensure we can hand off the file descriptor to the lockfile + result = { fileWriter: fileHandle, filePath: lockFilePath, dirtyWhenAcquired }; + fileHandle = undefined; + } finally { + if (fileHandle) { + fileHandle.close(); + } + } + + return result; +} diff --git a/libraries/node-core-library/src/PackageJsonLookup.ts b/libraries/node-core-library/src/PackageJsonLookup.ts index dc9e3f6853b..59efc981569 100644 --- a/libraries/node-core-library/src/PackageJsonLookup.ts +++ b/libraries/node-core-library/src/PackageJsonLookup.ts @@ -51,6 +51,8 @@ type ITryLoadPackageJsonInternalResult = | ITryLoadPackageJsonInternalKnownFailureResult | ITryLoadPackageJsonInternalUnknownFailureResult; +let _instance: PackageJsonLookup | undefined; + /** * This class provides methods for finding the nearest "package.json" for a folder * and retrieving the name of the package. The results are cached. @@ -58,8 +60,6 @@ type ITryLoadPackageJsonInternalResult = * @public */ export class PackageJsonLookup { - private static _instance: PackageJsonLookup | undefined; - /** * A singleton instance of `PackageJsonLookup`, which is useful for short-lived processes * that can reasonably assume that the file system will not be modified after the cache @@ -71,11 +71,11 @@ export class PackageJsonLookup { * of relying on this instance. */ public static get instance(): PackageJsonLookup { - if (!PackageJsonLookup._instance) { - PackageJsonLookup._instance = new PackageJsonLookup({ loadExtraFields: true }); + if (!_instance) { + _instance = new PackageJsonLookup({ loadExtraFields: true }); } - return PackageJsonLookup._instance; + return _instance; } private _loadExtraFields: boolean = false; diff --git a/libraries/node-core-library/src/PackageName.ts b/libraries/node-core-library/src/PackageName.ts index d96d2793759..bbcc0fef328 100644 --- a/libraries/node-core-library/src/PackageName.ts +++ b/libraries/node-core-library/src/PackageName.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +// encodeURIComponent() escapes all characters except: A-Z a-z 0-9 - _ . ! ~ * ' ( ) +// However, these are disallowed because they are shell characters: ! ~ * ' ( ) +const _invalidNameCharactersRegExp: RegExp = /[^A-Za-z0-9\-_\.]/; + /** * A package name that has been separated into its scope and unscoped name. * @@ -72,10 +76,6 @@ export interface IPackageNameParserOptions { * @public */ export class PackageNameParser { - // encodeURIComponent() escapes all characters except: A-Z a-z 0-9 - _ . ! ~ * ' ( ) - // However, these are disallowed because they are shell characters: ! ~ * ' ( ) - private static readonly _invalidNameCharactersRegExp: RegExp = /[^A-Za-z0-9\-_\.]/; - private readonly _options: IPackageNameParserOptions; public constructor(options: IPackageNameParserOptions = {}) { @@ -163,9 +163,7 @@ export class PackageNameParser { // "The name ends up being part of a URL, an argument on the command line, and a folder name. // Therefore, the name can't contain any non-URL-safe characters" - const match: RegExpMatchArray | null = nameWithoutScopeSymbols.match( - PackageNameParser._invalidNameCharactersRegExp - ); + const match: RegExpMatchArray | null = nameWithoutScopeSymbols.match(_invalidNameCharactersRegExp); if (match) { result.error = `The package name "${packageName}" contains an invalid character: "${match[0]}"`; return result; @@ -257,6 +255,8 @@ export class PackageNameParser { } } +const _parser: PackageNameParser = new PackageNameParser(); + /** * Provides basic operations for validating and manipulating NPM package names such as `my-package` * or `@scope/my-package`. @@ -268,40 +268,38 @@ export class PackageNameParser { * @public */ export class PackageName { - private static readonly _parser: PackageNameParser = new PackageNameParser(); - /** {@inheritDoc PackageNameParser.tryParse} */ public static tryParse(packageName: string): IParsedPackageNameOrError { - return PackageName._parser.tryParse(packageName); + return _parser.tryParse(packageName); } /** {@inheritDoc PackageNameParser.parse} */ public static parse(packageName: string): IParsedPackageName { - return this._parser.parse(packageName); + return _parser.parse(packageName); } /** {@inheritDoc PackageNameParser.getScope} */ public static getScope(packageName: string): string { - return this._parser.getScope(packageName); + return _parser.getScope(packageName); } /** {@inheritDoc PackageNameParser.getUnscopedName} */ public static getUnscopedName(packageName: string): string { - return this._parser.getUnscopedName(packageName); + return _parser.getUnscopedName(packageName); } /** {@inheritDoc PackageNameParser.isValidName} */ public static isValidName(packageName: string): boolean { - return this._parser.isValidName(packageName); + return _parser.isValidName(packageName); } /** {@inheritDoc PackageNameParser.validate} */ public static validate(packageName: string): void { - return this._parser.validate(packageName); + return _parser.validate(packageName); } /** {@inheritDoc PackageNameParser.combineParts} */ public static combineParts(scope: string, unscopedName: string): string { - return this._parser.combineParts(scope, unscopedName); + return _parser.combineParts(scope, unscopedName); } } diff --git a/libraries/node-core-library/src/Path.ts b/libraries/node-core-library/src/Path.ts index c6f0a2db1ea..a00484fe97b 100644 --- a/libraries/node-core-library/src/Path.ts +++ b/libraries/node-core-library/src/Path.ts @@ -67,6 +67,14 @@ export interface IPathFormatConciselyOptions { trimLeadingDotSlash?: boolean; } +// Matches a relative path consisting entirely of periods and slashes +// Example: ".", "..", "../..", etc +const _relativePathRegex: RegExp = /^[.\/\\]+$/; + +// Matches a relative path segment that traverses upwards +// Example: "a/../b" +const _upwardPathSegmentRegex: RegExp = /([\/\\]|^)\.\.([\/\\]|$)/; + /** * Common operations for manipulating file and directory paths. * @remarks @@ -74,14 +82,6 @@ export interface IPathFormatConciselyOptions { * @public */ export class Path { - // Matches a relative path consisting entirely of periods and slashes - // Example: ".", "..", "../..", etc - private static _relativePathRegex: RegExp = /^[.\/\\]+$/; - - // Matches a relative path segment that traverses upwards - // Example: "a/../b" - private static _upwardPathSegmentRegex: RegExp = /([\/\\]|^)\.\.([\/\\]|$)/; - /** * Returns true if "childPath" is located inside the "parentFolderPath" folder * or one of its child folders. Note that "parentFolderPath" is not considered to be @@ -97,7 +97,7 @@ export class Path { // "../.." or "..\\..", which consists entirely of periods and slashes. // (Note that something like "....t" is actually a valid filename, but "...." is not.) const relativePath: string = path.relative(childPath, parentFolderPath); - return Path._relativePathRegex.test(relativePath); + return _relativePathRegex.test(relativePath); } /** @@ -111,7 +111,7 @@ export class Path { */ public static isUnderOrEqual(childPath: string, parentFolderPath: string): boolean { const relativePath: string = path.relative(childPath, parentFolderPath); - return relativePath === '' || Path._relativePathRegex.test(relativePath); + return relativePath === '' || _relativePathRegex.test(relativePath); } /** @@ -137,7 +137,7 @@ export class Path { public static formatConcisely(options: IPathFormatConciselyOptions): string { // Same logic as Path.isUnderOrEqual() const relativePath: string = path.relative(options.pathToConvert, options.baseFolder); - const isUnderOrEqual: boolean = relativePath === '' || Path._relativePathRegex.test(relativePath); + const isUnderOrEqual: boolean = relativePath === '' || _relativePathRegex.test(relativePath); if (isUnderOrEqual) { // Note that isUnderOrEqual()'s relativePath is the reverse direction @@ -259,7 +259,7 @@ export class Path { return false; } // Does it contain ".." - if (Path._upwardPathSegmentRegex.test(inputPath)) { + if (_upwardPathSegmentRegex.test(inputPath)) { return false; } return true; diff --git a/libraries/node-core-library/src/SubprocessTerminator.ts b/libraries/node-core-library/src/SubprocessTerminator.ts index 0c256e6c7ba..1c2b8f45b1a 100644 --- a/libraries/node-core-library/src/SubprocessTerminator.ts +++ b/libraries/node-core-library/src/SubprocessTerminator.ts @@ -30,6 +30,19 @@ interface ITrackedSubprocess { subprocessOptions: ISubprocessOptions; } +/** + * Whether the hooks are installed + */ +let _initialized: boolean = false; + +/** + * The list of registered child processes. Processes are removed from this set if they + * terminate on their own. + */ +const _subprocessesByPid: Map = new Map(); + +const _isWindows: boolean = process.platform === 'win32'; + /** * When a child process is created, registering it with the SubprocessTerminator will ensure * that the child gets terminated when the current process terminates. @@ -44,19 +57,6 @@ interface ITrackedSubprocess { * @beta */ export class SubprocessTerminator { - /** - * Whether the hooks are installed - */ - private static _initialized: boolean = false; - - /** - * The list of registered child processes. Processes are removed from this set if they - * terminate on their own. - */ - private static _subprocessesByPid: Map = new Map(); - - private static readonly _isWindows: boolean = process.platform === 'win32'; - /** * The recommended options when creating a child process. */ @@ -77,9 +77,9 @@ export class SubprocessTerminator { return; } - SubprocessTerminator._validateSubprocessOptions(subprocessOptions); + _validateSubprocessOptions(subprocessOptions); - SubprocessTerminator._ensureInitialized(); + _ensureInitialized(); // Closure variable const pid: number | undefined = subprocess.pid; @@ -89,16 +89,16 @@ export class SubprocessTerminator { } subprocess.on('close', (exitCode: number | null, signal: NodeJS.Signals | null): void => { - if (SubprocessTerminator._subprocessesByPid.delete(pid)) { - SubprocessTerminator._logDebug(`untracking #${pid}`); + if (_subprocessesByPid.delete(pid)) { + _logDebug(`untracking #${pid}`); } }); - SubprocessTerminator._subprocessesByPid.set(pid, { + _subprocessesByPid.set(pid, { subprocess, subprocessOptions }); - SubprocessTerminator._logDebug(`tracking #${pid}`); + _logDebug(`tracking #${pid}`); } /** @@ -115,20 +115,20 @@ export class SubprocessTerminator { } // Don't attempt to kill the same process twice - if (SubprocessTerminator._subprocessesByPid.delete(pid)) { - SubprocessTerminator._logDebug(`untracking #${pid} via killProcessTree()`); + if (_subprocessesByPid.delete(pid)) { + _logDebug(`untracking #${pid} via killProcessTree()`); } - SubprocessTerminator._validateSubprocessOptions(subprocessOptions); + _validateSubprocessOptions(subprocessOptions); if (typeof subprocess.exitCode === 'number') { // Process has already been killed return; } - SubprocessTerminator._logDebug(`terminating #${pid}`); + _logDebug(`terminating #${pid}`); - if (SubprocessTerminator._isWindows) { + if (_isWindows) { // On Windows we have a problem that CMD.exe launches child processes, but when CMD.exe is killed // the child processes may continue running. Also if we send signals to CMD.exe the child processes // will not receive them. The safest solution is not to attempt a graceful shutdown, but simply @@ -156,94 +156,91 @@ export class SubprocessTerminator { process.kill(-pid, 'SIGKILL'); } } +} - // Install the hooks - private static _ensureInitialized(): void { - if (!SubprocessTerminator._initialized) { - SubprocessTerminator._initialized = true; +function _ensureInitialized(): void { + if (!_initialized) { + _initialized = true; - SubprocessTerminator._logDebug('initialize'); + _logDebug('initialize'); - process.prependListener('SIGTERM', SubprocessTerminator._onTerminateSignal); - process.prependListener('SIGINT', SubprocessTerminator._onTerminateSignal); + process.prependListener('SIGTERM', _onTerminateSignal); + process.prependListener('SIGINT', _onTerminateSignal); - process.prependListener('exit', SubprocessTerminator._onExit); - } + process.prependListener('exit', _onExit); } +} - // Uninstall the hooks and perform cleanup - private static _cleanupChildProcesses(): void { - if (SubprocessTerminator._initialized) { - SubprocessTerminator._initialized = false; +// Uninstall the hooks and perform cleanup +function _cleanupChildProcesses(): void { + if (_initialized) { + _initialized = false; - process.removeListener('SIGTERM', SubprocessTerminator._onTerminateSignal); - process.removeListener('SIGINT', SubprocessTerminator._onTerminateSignal); + process.removeListener('SIGTERM', _onTerminateSignal); + process.removeListener('SIGINT', _onTerminateSignal); - const trackedSubprocesses: ITrackedSubprocess[] = Array.from( - SubprocessTerminator._subprocessesByPid.values() - ); + const trackedSubprocesses: ITrackedSubprocess[] = Array.from(_subprocessesByPid.values()); - let firstError: Error | undefined = undefined; + let firstError: Error | undefined = undefined; - for (const trackedSubprocess of trackedSubprocesses) { - try { - SubprocessTerminator.killProcessTree(trackedSubprocess.subprocess, { detached: true }); - } catch (error) { - if (firstError === undefined) { - firstError = error as Error; - } + for (const trackedSubprocess of trackedSubprocesses) { + try { + SubprocessTerminator.killProcessTree(trackedSubprocess.subprocess, { detached: true }); + } catch (error) { + if (firstError === undefined) { + firstError = error as Error; } } + } - if (firstError !== undefined) { - // This is generally an unexpected error such as the TaskKill.exe command not being found, - // not a trivial issue such as a nonexistent PID. Since this occurs during process shutdown, - // we should not interfere with control flow by throwing an exception or calling process.exit(). - // So simply write to STDERR and ensure our exit code indicates the problem. - // eslint-disable-next-line no-console - console.error('\nAn unexpected error was encountered while attempting to clean up child processes:'); - // eslint-disable-next-line no-console - console.error(firstError.toString()); - if (!process.exitCode) { - process.exitCode = 1; - } + if (firstError !== undefined) { + // This is generally an unexpected error such as the TaskKill.exe command not being found, + // not a trivial issue such as a nonexistent PID. Since this occurs during process shutdown, + // we should not interfere with control flow by throwing an exception or calling process.exit(). + // So simply write to STDERR and ensure our exit code indicates the problem. + // eslint-disable-next-line no-console + console.error('\nAn unexpected error was encountered while attempting to clean up child processes:'); + // eslint-disable-next-line no-console + console.error(firstError.toString()); + if (!process.exitCode) { + process.exitCode = 1; } } } +} - private static _validateSubprocessOptions(subprocessOptions: ISubprocessOptions): void { - if (!SubprocessTerminator._isWindows) { - if (!subprocessOptions.detached) { - // Setting detached=true is what creates the process group that we use to kill the children - throw new Error('killProcessTree() requires detached=true on this operating system'); - } +function _validateSubprocessOptions(subprocessOptions: ISubprocessOptions): void { + if (!_isWindows) { + if (!subprocessOptions.detached) { + // Setting detached=true is what creates the process group that we use to kill the children + throw new Error('killProcessTree() requires detached=true on this operating system'); } } +} - private static _onExit(exitCode: number): void { - SubprocessTerminator._logDebug(`received exit(${exitCode})`); +function _onExit(exitCode: number): void { + _logDebug(`received exit(${exitCode})`); - SubprocessTerminator._cleanupChildProcesses(); + _cleanupChildProcesses(); - SubprocessTerminator._logDebug(`finished exit()`); - } + _logDebug(`finished exit()`); +} - private static _onTerminateSignal(signal: string): void { - SubprocessTerminator._logDebug(`received signal ${signal}`); +function _onTerminateSignal(signal: string): void { + _logDebug(`received signal ${signal}`); - SubprocessTerminator._cleanupChildProcesses(); + _cleanupChildProcesses(); - // When a listener is added to SIGTERM, Node.js strangely provides no way to reference - // the original handler. But we can invoke it by removing our listener and then resending - // the signal to our own process. - SubprocessTerminator._logDebug(`relaying ${signal}`); - process.kill(process.pid, signal); - } + // When a listener is added to SIGTERM, Node.js strangely provides no way to reference + // the original handler. But we can invoke it by removing our listener and then resending + // the signal to our own process. + _logDebug(`relaying ${signal}`); + process.kill(process.pid, signal); +} - // For debugging - private static _logDebug(message: string): void { - //const logLine: string = `SubprocessTerminator: [${process.pid}] ${message}`; - // fs.writeFileSync('trace.log', logLine + '\n', { flag: 'a' }); - //console.log(logLine); - } +// For debugging +function _logDebug(message: string): void { + //const logLine: string = `SubprocessTerminator: [${process.pid}] ${message}`; + // fs.writeFileSync('trace.log', logLine + '\n', { flag: 'a' }); + //console.log(logLine); } diff --git a/libraries/node-core-library/src/Text.ts b/libraries/node-core-library/src/Text.ts index 4630a9328d8..465c240c5c0 100644 --- a/libraries/node-core-library/src/Text.ts +++ b/libraries/node-core-library/src/Text.ts @@ -60,6 +60,9 @@ interface IReadLinesFromIterableState { const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; const NEWLINE_AT_END_REGEX: RegExp = /(\r\n|\n\r|\r|\n)$/; +const _newLineRegEx: RegExp = NEWLINE_REGEX; +const _newLineAtEndRegEx: RegExp = NEWLINE_AT_END_REGEX; + function* readLinesFromChunk( // eslint-disable-next-line @rushstack/no-new-null chunk: string | Buffer | null, @@ -93,9 +96,6 @@ function* readLinesFromChunk( * @public */ export class Text { - private static readonly _newLineRegEx: RegExp = NEWLINE_REGEX; - private static readonly _newLineAtEndRegEx: RegExp = NEWLINE_AT_END_REGEX; - /** * Returns the same thing as targetString.replace(searchValue, replaceValue), except that * all matches are replaced, rather than just the first match. @@ -111,7 +111,7 @@ export class Text { * Converts all newlines in the provided string to use Windows-style CRLF end of line characters. */ public static convertToCrLf(input: string): string { - return input.replace(Text._newLineRegEx, '\r\n'); + return input.replace(_newLineRegEx, '\r\n'); } /** @@ -120,14 +120,14 @@ export class Text { * POSIX is a registered trademark of the Institute of Electrical and Electronic Engineers, Inc. */ public static convertToLf(input: string): string { - return input.replace(Text._newLineRegEx, '\n'); + return input.replace(_newLineRegEx, '\n'); } /** * Converts all newlines in the provided string to use the specified newline type. */ public static convertTo(input: string, newlineKind: NewlineKind): string { - return input.replace(Text._newLineRegEx, Text.getNewline(newlineKind)); + return input.replace(_newLineRegEx, Text.getNewline(newlineKind)); } /** @@ -214,7 +214,7 @@ export class Text { */ public static ensureTrailingNewline(s: string, newlineKind: NewlineKind = NewlineKind.Lf): string { // Is there already a newline? - if (Text._newLineAtEndRegEx.test(s)) { + if (_newLineAtEndRegEx.test(s)) { return s; // yes, no change } return s + newlineKind; // no, add it diff --git a/libraries/node-core-library/src/TypeUuid.ts b/libraries/node-core-library/src/TypeUuid.ts index 7637e3f7f95..a28d0f7ff96 100644 --- a/libraries/node-core-library/src/TypeUuid.ts +++ b/libraries/node-core-library/src/TypeUuid.ts @@ -5,6 +5,8 @@ import { InternalError } from './InternalError'; const classPrototypeUuidSymbol: symbol = Symbol.for('TypeUuid.classPrototypeUuid'); +const _uuidRegExp: RegExp = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; + /** * Provides a version-independent implementation of the JavaScript `instanceof` operator. * @@ -39,8 +41,6 @@ const classPrototypeUuidSymbol: symbol = Symbol.for('TypeUuid.classPrototypeUuid * @public */ export class TypeUuid { - private static _uuidRegExp: RegExp = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; - /** * Registers a JavaScript class as having a type identified by the specified UUID. * @privateRemarks @@ -52,7 +52,7 @@ export class TypeUuid { throw new Error('The targetClass parameter must be a JavaScript class'); } - if (!TypeUuid._uuidRegExp.test(typeUuid)) { + if (!_uuidRegExp.test(typeUuid)) { throw new Error(`The type UUID must be specified as lowercase hexadecimal with dashes: "${typeUuid}"`); } diff --git a/libraries/node-core-library/src/test/LockFile.test.ts b/libraries/node-core-library/src/test/LockFile.test.ts index f3ccdde992c..9c335ecd8e2 100644 --- a/libraries/node-core-library/src/test/LockFile.test.ts +++ b/libraries/node-core-library/src/test/LockFile.test.ts @@ -2,13 +2,17 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; -import { LockFile, getProcessStartTime, getProcessStartTimeFromProcStat } from '../LockFile'; +import { + LockFile, + getProcessStartTime, + getProcessStartTimeFromProcStat, + _setLockFileGetProcessStartTime +} from '../LockFile'; import { FileSystem } from '../FileSystem'; import { FileWriter } from '../FileWriter'; function setLockFileGetProcessStartTime(fn: (process: number) => string | undefined): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (LockFile as any)._getStartTime = fn; + _setLockFileGetProcessStartTime(fn); } // lib/test diff --git a/libraries/package-extractor/src/PackageExtractor.ts b/libraries/package-extractor/src/PackageExtractor.ts index c2f4c790f7f..c9e381e818f 100644 --- a/libraries/package-extractor/src/PackageExtractor.ts +++ b/libraries/package-extractor/src/PackageExtractor.ts @@ -360,7 +360,7 @@ export class PackageExtractor { * Extract a package using the provided options */ public async extractAsync(options: IExtractorOptions): Promise { - options = PackageExtractor._normalizeOptions(options); + options = _normalizeOptions(options); const { terminal, projectConfigurations, @@ -431,36 +431,6 @@ export class PackageExtractor { }); } - private static _normalizeOptions(options: IExtractorOptions): IExtractorOptions { - if (options.subspaces) { - if (options.pnpmInstallFolder !== undefined) { - throw new Error( - 'IExtractorOptions.pnpmInstallFolder cannot be combined with IExtractorOptions.subspaces' - ); - } - if (options.transformPackageJson !== undefined) { - throw new Error( - 'IExtractorOptions.transformPackageJson cannot be combined with IExtractorOptions.subspaces' - ); - } - return options; - } - - const normalizedOptions: IExtractorOptions = { ...options }; - delete normalizedOptions.pnpmInstallFolder; - delete normalizedOptions.transformPackageJson; - - normalizedOptions.subspaces = [ - { - subspaceName: 'default', - pnpmInstallFolder: options.pnpmInstallFolder, - transformPackageJson: options.transformPackageJson - } - ]; - - return normalizedOptions; - } - private async _performExtractionAsync(options: IExtractorOptions, state: IExtractorState): Promise { const { terminal, @@ -1012,3 +982,33 @@ export class PackageExtractor { }); } } + +function _normalizeOptions(options: IExtractorOptions): IExtractorOptions { + if (options.subspaces) { + if (options.pnpmInstallFolder !== undefined) { + throw new Error( + 'IExtractorOptions.pnpmInstallFolder cannot be combined with IExtractorOptions.subspaces' + ); + } + if (options.transformPackageJson !== undefined) { + throw new Error( + 'IExtractorOptions.transformPackageJson cannot be combined with IExtractorOptions.subspaces' + ); + } + return options; + } + + const normalizedOptions: IExtractorOptions = { ...options }; + delete normalizedOptions.pnpmInstallFolder; + delete normalizedOptions.transformPackageJson; + + normalizedOptions.subspaces = [ + { + subspaceName: 'default', + pnpmInstallFolder: options.pnpmInstallFolder, + transformPackageJson: options.transformPackageJson + } + ]; + + return normalizedOptions; +} diff --git a/libraries/rig-package/src/Helpers.ts b/libraries/rig-package/src/Helpers.ts index d2d0fd9282b..49457a4f7fd 100644 --- a/libraries/rig-package/src/Helpers.ts +++ b/libraries/rig-package/src/Helpers.ts @@ -7,10 +7,11 @@ import * as fs from 'node:fs'; import nodeResolve from 'resolve'; // These helpers avoid taking dependencies on other NPM packages -export class Helpers { - // Based on Path.isDownwardRelative() from @rushstack/node-core-library - private static _upwardPathSegmentRegex: RegExp = /([\/\\]|^)\.\.([\/\\]|$)/; +// Based on Path.isDownwardRelative() from @rushstack/node-core-library +const _upwardPathSegmentRegex: RegExp = /([\/\\]|^)\.\.([\/\\]|$)/; + +export class Helpers { public static async nodeResolveAsync(id: string, opts: nodeResolve.AsyncOpts): Promise { return await new Promise((resolve: (result: string) => void, reject: (error: Error) => void) => { nodeResolve(id, opts, (error: Error | null, result: string | undefined) => { @@ -37,7 +38,7 @@ export class Helpers { return false; } // Does it contain ".." - if (Helpers._upwardPathSegmentRegex.test(inputPath)) { + if (_upwardPathSegmentRegex.test(inputPath)) { return false; } return true; diff --git a/libraries/rig-package/src/RigConfig.ts b/libraries/rig-package/src/RigConfig.ts index 23b75566072..28cdf252338 100644 --- a/libraries/rig-package/src/RigConfig.ts +++ b/libraries/rig-package/src/RigConfig.ts @@ -175,22 +175,26 @@ export interface IRigConfig { tryResolveConfigFilePathAsync(configFileRelativePath: string): Promise; } +// For syntax details, see PackageNameParser from @rushstack/node-core-library +const _packageNameRegExp: RegExp = /^(@[A-Za-z0-9\-_\.]+\/)?[A-Za-z0-9\-_\.]+$/; + +// Rig package names must have the "-rig" suffix. +// Also silently accept "-rig-test" for our build test projects. +const _rigNameRegExp: RegExp = /-rig(-test)?$/; + +// Profiles must be lowercase alphanumeric words separated by hyphens +const _profileNameRegExp: RegExp = /^[a-z0-9_\.]+(\-[a-z0-9_\.]+)*$/; + +let _jsonSchemaObject: object | undefined = undefined; + +const _configCache: Map = new Map(); + /** * {@inheritdoc IRigConfig} * * @public */ export class RigConfig implements IRigConfig { - // For syntax details, see PackageNameParser from @rushstack/node-core-library - private static readonly _packageNameRegExp: RegExp = /^(@[A-Za-z0-9\-_\.]+\/)?[A-Za-z0-9\-_\.]+$/; - - // Rig package names must have the "-rig" suffix. - // Also silently accept "-rig-test" for our build test projects. - private static readonly _rigNameRegExp: RegExp = /-rig(-test)?$/; - - // Profiles must be lowercase alphanumeric words separated by hyphens - private static readonly _profileNameRegExp: RegExp = /^[a-z0-9_\.]+(\-[a-z0-9_\.]+)*$/; - /** * Returns the absolute path of the `rig.schema.json` JSON schema file for `config/rig.json`, * which is bundled with this NPM package. @@ -202,9 +206,6 @@ export class RigConfig implements IRigConfig { * @public */ public static jsonSchemaPath: string = path.resolve(__dirname, './schemas/rig.schema.json'); - private static _jsonSchemaObject: object | undefined = undefined; - - private static readonly _configCache: Map = new Map(); /** * {@inheritdoc IRigConfig.projectFolderOriginalPath} @@ -276,11 +277,11 @@ export class RigConfig implements IRigConfig { * Accessing this property may make a synchronous filesystem call. */ public static get jsonSchemaObject(): object { - if (RigConfig._jsonSchemaObject === undefined) { + if (_jsonSchemaObject === undefined) { const jsonSchemaContent: string = fs.readFileSync(RigConfig.jsonSchemaPath).toString(); - RigConfig._jsonSchemaObject = JSON.parse(jsonSchemaContent); + _jsonSchemaObject = JSON.parse(jsonSchemaContent); } - return RigConfig._jsonSchemaObject!; + return _jsonSchemaObject!; } /** @@ -294,9 +295,7 @@ export class RigConfig implements IRigConfig { const { overrideRigJsonObject, projectFolderPath } = options; const fromCache: RigConfig | undefined = - !options.bypassCache && !overrideRigJsonObject - ? RigConfig._configCache.get(projectFolderPath) - : undefined; + !options.bypassCache && !overrideRigJsonObject ? _configCache.get(projectFolderPath) : undefined; if (fromCache) { return fromCache; @@ -311,9 +310,9 @@ export class RigConfig implements IRigConfig { const rigConfigFileContent: string = fs.readFileSync(rigConfigFilePath).toString(); json = jju.parse(rigConfigFileContent) as IRigConfigJson; } - RigConfig._validateSchema(json); + _validateSchema(json); } catch (error) { - config = RigConfig._handleConfigError(error as Error, projectFolderPath, rigConfigFilePath); + config = new RigConfig(_handleConfigError(error as Error, projectFolderPath, rigConfigFilePath)); } if (!config) { @@ -328,7 +327,7 @@ export class RigConfig implements IRigConfig { } if (!overrideRigJsonObject) { - RigConfig._configCache.set(projectFolderPath, config); + _configCache.set(projectFolderPath, config); } return config; } @@ -340,7 +339,7 @@ export class RigConfig implements IRigConfig { const { overrideRigJsonObject, projectFolderPath } = options; const fromCache: RigConfig | false | undefined = - !options.bypassCache && !overrideRigJsonObject && RigConfig._configCache.get(projectFolderPath); + !options.bypassCache && !overrideRigJsonObject && _configCache.get(projectFolderPath); if (fromCache) { return fromCache; @@ -356,9 +355,9 @@ export class RigConfig implements IRigConfig { json = jju.parse(rigConfigFileContent) as IRigConfigJson; } - RigConfig._validateSchema(json); + _validateSchema(json); } catch (error) { - config = RigConfig._handleConfigError(error as Error, projectFolderPath, rigConfigFilePath); + config = new RigConfig(_handleConfigError(error as Error, projectFolderPath, rigConfigFilePath)); } if (!config) { @@ -373,31 +372,11 @@ export class RigConfig implements IRigConfig { } if (!overrideRigJsonObject) { - RigConfig._configCache.set(projectFolderPath, config); + _configCache.set(projectFolderPath, config); } return config; } - private static _handleConfigError( - error: NodeJS.ErrnoException, - projectFolderPath: string, - rigConfigFilePath: string - ): RigConfig { - if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') { - throw new Error(error.message + '\nError loading config file: ' + rigConfigFilePath); - } - - // File not found, i.e. no rig config - return new RigConfig({ - projectFolderPath, - - rigFound: false, - filePath: '', - rigPackageName: '', - rigProfile: '' - }); - } - /** * {@inheritdoc IRigConfig.getResolvedProfileFolder} */ @@ -508,41 +487,61 @@ export class RigConfig implements IRigConfig { } return undefined; } +} - private static _validateSchema(json: IRigConfigJson): void { - for (const key of Object.getOwnPropertyNames(json)) { - switch (key) { - case '$schema': - case 'rigPackageName': - case 'rigProfile': - break; - default: - throw new Error(`Unsupported field ${JSON.stringify(key)}`); - } - } - if (!json.rigPackageName) { - throw new Error('Missing required field "rigPackageName"'); - } +function _handleConfigError( + error: NodeJS.ErrnoException, + projectFolderPath: string, + rigConfigFilePath: string +): IRigConfigOptions { + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') { + throw new Error(error.message + '\nError loading config file: ' + rigConfigFilePath); + } - if (!RigConfig._packageNameRegExp.test(json.rigPackageName)) { - throw new Error( - `The "rigPackageName" value is not a valid NPM package name: ${JSON.stringify(json.rigPackageName)}` - ); + // File not found, i.e. no rig config + return { + projectFolderPath, + + rigFound: false, + filePath: '', + rigPackageName: '', + rigProfile: '' + }; +} + +function _validateSchema(json: IRigConfigJson): void { + for (const key of Object.getOwnPropertyNames(json)) { + switch (key) { + case '$schema': + case 'rigPackageName': + case 'rigProfile': + break; + default: + throw new Error(`Unsupported field ${JSON.stringify(key)}`); } + } + if (!json.rigPackageName) { + throw new Error('Missing required field "rigPackageName"'); + } - if (!RigConfig._rigNameRegExp.test(json.rigPackageName)) { + if (!_packageNameRegExp.test(json.rigPackageName)) { + throw new Error( + `The "rigPackageName" value is not a valid NPM package name: ${JSON.stringify(json.rigPackageName)}` + ); + } + + if (!_rigNameRegExp.test(json.rigPackageName)) { + throw new Error( + `The "rigPackageName" value is missing the "-rig" suffix: ` + JSON.stringify(json.rigProfile) + ); + } + + if (json.rigProfile !== undefined) { + if (!_profileNameRegExp.test(json.rigProfile)) { throw new Error( - `The "rigPackageName" value is missing the "-rig" suffix: ` + JSON.stringify(json.rigProfile) + `The profile name must consist of lowercase alphanumeric words separated by hyphens: ` + + JSON.stringify(json.rigProfile) ); } - - if (json.rigProfile !== undefined) { - if (!RigConfig._profileNameRegExp.test(json.rigProfile)) { - throw new Error( - `The profile name must consist of lowercase alphanumeric words separated by hyphens: ` + - JSON.stringify(json.rigProfile) - ); - } - } } } diff --git a/libraries/rush-lib/src/api/ApprovedPackagesConfiguration.ts b/libraries/rush-lib/src/api/ApprovedPackagesConfiguration.ts index e7b48f2c842..6bbb3b7b412 100644 --- a/libraries/rush-lib/src/api/ApprovedPackagesConfiguration.ts +++ b/libraries/rush-lib/src/api/ApprovedPackagesConfiguration.ts @@ -50,13 +50,13 @@ export class ApprovedPackagesItem { } } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * This represents the JSON file specified via the "approvedPackagesFile" option in rush.json. * @public */ export class ApprovedPackagesConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - public items: ApprovedPackagesItem[] = []; private _itemsByName: Map = new Map(); @@ -130,7 +130,7 @@ export class ApprovedPackagesConfiguration { public loadFromFile(): void { const approvedPackagesJson: IApprovedPackagesJson = JsonFile.loadAndValidate( this._jsonFilename, - ApprovedPackagesConfiguration._jsonSchema + _jsonSchema ); this.clear(); diff --git a/libraries/rush-lib/src/api/BuildCacheConfiguration.ts b/libraries/rush-lib/src/api/BuildCacheConfiguration.ts index d1480c164a2..f374397c552 100644 --- a/libraries/rush-lib/src/api/BuildCacheConfiguration.ts +++ b/libraries/rush-lib/src/api/BuildCacheConfiguration.ts @@ -145,12 +145,8 @@ export class BuildCacheConfiguration { rushConfiguration: RushConfiguration, rushSession: RushSession ): Promise { - const { buildCacheConfiguration } = await BuildCacheConfiguration._tryLoadInternalAsync( - terminal, - rushConfiguration, - rushSession - ); - return buildCacheConfiguration; + const { options } = await _tryLoadInternalAsync(terminal, rushConfiguration, rushSession); + return options ? new BuildCacheConfiguration(options) : undefined; } /** @@ -162,11 +158,10 @@ export class BuildCacheConfiguration { rushConfiguration: RushConfiguration, rushSession: RushSession ): Promise { - const { buildCacheConfiguration, jsonFilePath } = await BuildCacheConfiguration._tryLoadInternalAsync( - terminal, - rushConfiguration, - rushSession - ); + const { options, jsonFilePath } = await _tryLoadInternalAsync(terminal, rushConfiguration, rushSession); + const buildCacheConfiguration: BuildCacheConfiguration | undefined = options + ? new BuildCacheConfiguration(options) + : undefined; if (!buildCacheConfiguration) { terminal.writeErrorLine( @@ -193,102 +188,103 @@ export class BuildCacheConfiguration { public static getBuildCacheConfigFilePath(rushConfiguration: RushConfiguration): string { return `${rushConfiguration.commonRushConfigFolder}/${RushConstants.buildCacheFilename}`; } +} - private static async _tryLoadInternalAsync( - terminal: ITerminal, - rushConfiguration: RushConfiguration, - rushSession: RushSession - ): Promise<{ buildCacheConfiguration: BuildCacheConfiguration | undefined; jsonFilePath: string }> { - const jsonFilePath: string = BuildCacheConfiguration.getBuildCacheConfigFilePath(rushConfiguration); - const buildCacheConfiguration: BuildCacheConfiguration | undefined = - await BuildCacheConfiguration._tryLoadAsync(jsonFilePath, terminal, rushConfiguration, rushSession); - return { buildCacheConfiguration, jsonFilePath }; - } +async function _tryLoadInternalAsync( + terminal: ITerminal, + rushConfiguration: RushConfiguration, + rushSession: RushSession +): Promise<{ options: IBuildCacheConfigurationOptions | undefined; jsonFilePath: string }> { + const jsonFilePath: string = BuildCacheConfiguration.getBuildCacheConfigFilePath(rushConfiguration); + const options: IBuildCacheConfigurationOptions | undefined = await _tryLoadAsync( + jsonFilePath, + terminal, + rushConfiguration, + rushSession + ); + return { options, jsonFilePath }; +} - private static async _tryLoadAsync( - jsonFilePath: string, - terminal: ITerminal, - rushConfiguration: RushConfiguration, - rushSession: RushSession - ): Promise { - let buildCacheJson: IBuildCacheJson; - const buildCacheOverrideJson: string | undefined = EnvironmentConfiguration.buildCacheOverrideJson; - if (buildCacheOverrideJson) { - buildCacheJson = JsonFile.parseString(buildCacheOverrideJson); - BUILD_CACHE_JSON_SCHEMA.validateObject( - buildCacheJson, - `${EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON} environment variable` +async function _tryLoadAsync( + jsonFilePath: string, + terminal: ITerminal, + rushConfiguration: RushConfiguration, + rushSession: RushSession +): Promise { + let buildCacheJson: IBuildCacheJson; + const buildCacheOverrideJson: string | undefined = EnvironmentConfiguration.buildCacheOverrideJson; + if (buildCacheOverrideJson) { + buildCacheJson = JsonFile.parseString(buildCacheOverrideJson); + BUILD_CACHE_JSON_SCHEMA.validateObject( + buildCacheJson, + `${EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON} environment variable` + ); + } else { + const buildCacheOverrideJsonFilePath: string | undefined = + EnvironmentConfiguration.buildCacheOverrideJsonFilePath; + if (buildCacheOverrideJsonFilePath) { + buildCacheJson = await JsonFile.loadAndValidateAsync( + buildCacheOverrideJsonFilePath, + BUILD_CACHE_JSON_SCHEMA ); } else { - const buildCacheOverrideJsonFilePath: string | undefined = - EnvironmentConfiguration.buildCacheOverrideJsonFilePath; - if (buildCacheOverrideJsonFilePath) { - buildCacheJson = await JsonFile.loadAndValidateAsync( - buildCacheOverrideJsonFilePath, - BUILD_CACHE_JSON_SCHEMA - ); - } else { - try { - buildCacheJson = await JsonFile.loadAndValidateAsync(jsonFilePath, BUILD_CACHE_JSON_SCHEMA); - } catch (e) { - if (!FileSystem.isNotExistError(e)) { - throw e; - } else { - return undefined; - } + try { + buildCacheJson = await JsonFile.loadAndValidateAsync(jsonFilePath, BUILD_CACHE_JSON_SCHEMA); + } catch (e) { + if (!FileSystem.isNotExistError(e)) { + throw e; + } else { + return undefined; } } } + } - const rushUserConfiguration: RushUserConfiguration = await RushUserConfiguration.initializeAsync(); - let innerGetCacheEntryId: GetCacheEntryIdFunction; - try { - innerGetCacheEntryId = CacheEntryId.parsePattern(buildCacheJson.cacheEntryNamePattern); - } catch (e) { - terminal.writeErrorLine( - `Error parsing cache entry name pattern "${buildCacheJson.cacheEntryNamePattern}": ${e}` - ); - throw new AlreadyReportedError(); - } + const rushUserConfiguration: RushUserConfiguration = await RushUserConfiguration.initializeAsync(); + let innerGetCacheEntryId: GetCacheEntryIdFunction; + try { + innerGetCacheEntryId = CacheEntryId.parsePattern(buildCacheJson.cacheEntryNamePattern); + } catch (e) { + terminal.writeErrorLine( + `Error parsing cache entry name pattern "${buildCacheJson.cacheEntryNamePattern}": ${e}` + ); + throw new AlreadyReportedError(); + } - const { cacheHashSalt = '', cacheProvider } = buildCacheJson; - const salt: string = `${RushConstants.buildCacheVersion}${ - cacheHashSalt ? `${RushConstants.hashDelimiter}${cacheHashSalt}` : '' - }`; - // Extend the cache entry id with to salt the hash - // This facilitates forcing cache invalidation either when the build cache version changes (new version of Rush) - // or when the user-side salt changes (need to purge bad cache entries, plugins including additional files) - const getCacheEntryId: GetCacheEntryIdFunction = (options: IGenerateCacheEntryIdOptions): string => { - const saltedHash: string = createHash('sha1') - .update(salt) - .update(options.projectStateHash) - .digest('hex'); + const { cacheHashSalt = '', cacheProvider } = buildCacheJson; + const salt: string = `${RushConstants.buildCacheVersion}${ + cacheHashSalt ? `${RushConstants.hashDelimiter}${cacheHashSalt}` : '' + }`; + // Extend the cache entry id with to salt the hash + // This facilitates forcing cache invalidation either when the build cache version changes (new version of Rush) + // or when the user-side salt changes (need to purge bad cache entries, plugins including additional files) + const getCacheEntryId: GetCacheEntryIdFunction = (options: IGenerateCacheEntryIdOptions): string => { + const saltedHash: string = createHash('sha1').update(salt).update(options.projectStateHash).digest('hex'); - return innerGetCacheEntryId({ - phaseName: options.phaseName, - projectName: options.projectName, - projectStateHash: saltedHash - }); - }; + return innerGetCacheEntryId({ + phaseName: options.phaseName, + projectName: options.projectName, + projectStateHash: saltedHash + }); + }; - let cloudCacheProvider: ICloudBuildCacheProvider | undefined; - // Don't configure a cloud cache provider if local-only - if (cacheProvider !== 'local-only') { - const cloudCacheProviderFactory: CloudBuildCacheProviderFactory | undefined = - rushSession.getCloudBuildCacheProviderFactory(cacheProvider); - if (!cloudCacheProviderFactory) { - throw new Error(`Unexpected cache provider: ${cacheProvider}`); - } - cloudCacheProvider = await cloudCacheProviderFactory(buildCacheJson as ICloudBuildCacheJson); + let cloudCacheProvider: ICloudBuildCacheProvider | undefined; + // Don't configure a cloud cache provider if local-only + if (cacheProvider !== 'local-only') { + const cloudCacheProviderFactory: CloudBuildCacheProviderFactory | undefined = + rushSession.getCloudBuildCacheProviderFactory(cacheProvider); + if (!cloudCacheProviderFactory) { + throw new Error(`Unexpected cache provider: ${cacheProvider}`); } - - return new BuildCacheConfiguration({ - buildCacheJson, - getCacheEntryId, - rushConfiguration, - rushUserConfiguration, - rushSession, - cloudCacheProvider - }); + cloudCacheProvider = await cloudCacheProviderFactory(buildCacheJson as ICloudBuildCacheJson); } + + return { + buildCacheJson, + getCacheEntryId, + rushConfiguration, + rushUserConfiguration, + rushSession, + cloudCacheProvider + }; } diff --git a/libraries/rush-lib/src/api/CobuildConfiguration.ts b/libraries/rush-lib/src/api/CobuildConfiguration.ts index d96cfc96dc2..e397018ca44 100644 --- a/libraries/rush-lib/src/api/CobuildConfiguration.ts +++ b/libraries/rush-lib/src/api/CobuildConfiguration.ts @@ -31,14 +31,14 @@ export interface ICobuildConfigurationOptions { cobuildLockProviderFactory: CobuildLockProviderFactory; } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * Use this class to load and save the "common/config/rush/cobuild.json" config file. * This file provides configuration options for the Rush Cobuild feature. * @beta */ export class CobuildConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - /** * Indicates whether the cobuild feature is enabled. * Typically it is enabled in the cobuild.json config file. @@ -106,7 +106,13 @@ export class CobuildConfiguration { ): Promise { const jsonFilePath: string = CobuildConfiguration.getCobuildConfigFilePath(rushConfiguration); try { - return await CobuildConfiguration._loadAsync(jsonFilePath, terminal, rushConfiguration, rushSession); + const options: ICobuildConfigurationOptions | undefined = await _loadAsync( + jsonFilePath, + terminal, + rushConfiguration, + rushSession + ); + return options ? new CobuildConfiguration(options) : undefined; } catch (err) { if (!FileSystem.isNotExistError(err)) { throw err; @@ -118,40 +124,6 @@ export class CobuildConfiguration { return `${rushConfiguration.commonRushConfigFolder}/${RushConstants.cobuildFilename}`; } - private static async _loadAsync( - jsonFilePath: string, - terminal: ITerminal, - rushConfiguration: RushConfiguration, - rushSession: RushSession - ): Promise { - let cobuildJson: ICobuildJson | undefined; - try { - cobuildJson = await JsonFile.loadAndValidateAsync(jsonFilePath, CobuildConfiguration._jsonSchema); - } catch (e) { - if (FileSystem.isNotExistError(e)) { - return undefined; - } - throw e; - } - - if (!cobuildJson?.cobuildFeatureEnabled) { - return undefined; - } - - const cobuildLockProviderFactory: CobuildLockProviderFactory | undefined = - rushSession.getCobuildLockProviderFactory(cobuildJson.cobuildLockProvider); - if (!cobuildLockProviderFactory) { - throw new Error(`Unexpected cobuild lock provider: ${cobuildJson.cobuildLockProvider}`); - } - - return new CobuildConfiguration({ - cobuildJson, - rushConfiguration, - rushSession, - cobuildLockProviderFactory - }); - } - public async createLockProviderAsync(terminal: ITerminal): Promise { if (this.cobuildFeatureEnabled) { terminal.writeLine(`Running cobuild (runner ${this.cobuildContextId}/${this.cobuildRunnerId})`); @@ -176,3 +148,37 @@ export class CobuildConfiguration { return this._cobuildLockProvider; } } + +async function _loadAsync( + jsonFilePath: string, + terminal: ITerminal, + rushConfiguration: RushConfiguration, + rushSession: RushSession +): Promise { + let cobuildJson: ICobuildJson | undefined; + try { + cobuildJson = await JsonFile.loadAndValidateAsync(jsonFilePath, _jsonSchema); + } catch (e) { + if (FileSystem.isNotExistError(e)) { + return undefined; + } + throw e; + } + + if (!cobuildJson?.cobuildFeatureEnabled) { + return undefined; + } + + const cobuildLockProviderFactory: CobuildLockProviderFactory | undefined = + rushSession.getCobuildLockProviderFactory(cobuildJson.cobuildLockProvider); + if (!cobuildLockProviderFactory) { + throw new Error(`Unexpected cobuild lock provider: ${cobuildJson.cobuildLockProvider}`); + } + + return { + cobuildJson, + rushConfiguration, + rushSession, + cobuildLockProviderFactory + }; +} diff --git a/libraries/rush-lib/src/api/CommandLineConfiguration.ts b/libraries/rush-lib/src/api/CommandLineConfiguration.ts index 9ea78551ec0..53b5678f4ff 100644 --- a/libraries/rush-lib/src/api/CommandLineConfiguration.ts +++ b/libraries/rush-lib/src/api/CommandLineConfiguration.ts @@ -210,12 +210,12 @@ function _normalizeNameForLogFilenameIdentifiers(name: string): string { return name.replace(/:/g, '_'); // Replace colons with underscores to be filesystem-safe } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * Custom Commands and Options for the Rush Command Line */ export class CommandLineConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - public readonly commands: Map = new Map(); public readonly phases: Map = new Map(); public readonly parameters: IParameterJson[] = []; @@ -628,42 +628,6 @@ export class CommandLineConfiguration { cycleFreePhases.add(phase); } - private static _applyBuildCommandDefaults(commandLineJson: ICommandLineJson): void { - // merge commands specified in command-line.json and default (re)build settings - // Ensure both build commands are included and preserve any other commands specified - if (commandLineJson?.commands) { - for (let i: number = 0; i < commandLineJson.commands.length; i++) { - const command: CommandJson = commandLineJson.commands[i]; - - // Determine if we have a set of default parameters - let commandDefaultDefinition: CommandJson | {} = {}; - switch (command.commandKind) { - case RushConstants.phasedCommandKind: - case RushConstants.bulkCommandKind: { - switch (command.name) { - case RushConstants.buildCommandName: { - commandDefaultDefinition = DEFAULT_BUILD_COMMAND_JSON; - break; - } - - case RushConstants.rebuildCommandName: { - commandDefaultDefinition = DEFAULT_REBUILD_COMMAND_JSON; - break; - } - } - break; - } - } - - // Merge the default parameters into the repo-specified parameters - commandLineJson.commands[i] = { - ...commandDefaultDefinition, - ...command - }; - } - } - } - /** * Load the command-line.json configuration file from the specified path. Note that this * does not include the default build settings. This option is intended to be used to load @@ -675,7 +639,7 @@ export class CommandLineConfiguration { public static tryLoadFromFile(jsonFilePath: string): CommandLineConfiguration | undefined { let commandLineJson: ICommandLineJson | undefined; try { - commandLineJson = JsonFile.loadAndValidate(jsonFilePath, CommandLineConfiguration._jsonSchema); + commandLineJson = JsonFile.loadAndValidate(jsonFilePath, _jsonSchema); } catch (e) { if (!FileSystem.isNotExistError(e as Error)) { throw e; @@ -683,7 +647,7 @@ export class CommandLineConfiguration { } if (commandLineJson) { - this._applyBuildCommandDefaults(commandLineJson); + _applyBuildCommandDefaults(commandLineJson); const hasBuildCommand: boolean = !!commandLineJson.commands?.some( (command) => command.name === RushConstants.buildCommandName ); @@ -721,9 +685,9 @@ export class CommandLineConfiguration { // merge commands specified in command-line.json and default (re)build settings // Ensure both build commands are included and preserve any other commands specified if (commandLineJson?.commands) { - this._applyBuildCommandDefaults(commandLineJson); + _applyBuildCommandDefaults(commandLineJson); - CommandLineConfiguration._jsonSchema.validateObject(commandLineJson, jsonFilePath); + _jsonSchema.validateObject(commandLineJson, jsonFilePath); // Validate that globalPlugin commands are not used in the repo's command-line.json for (const { commandKind, name } of commandLineJson.commands) { @@ -786,3 +750,39 @@ export class CommandLineConfiguration { return translatedCommand; } } + +function _applyBuildCommandDefaults(commandLineJson: ICommandLineJson): void { + // merge commands specified in command-line.json and default (re)build settings + // Ensure both build commands are included and preserve any other commands specified + if (commandLineJson?.commands) { + for (let i: number = 0; i < commandLineJson.commands.length; i++) { + const command: CommandJson = commandLineJson.commands[i]; + + // Determine if we have a set of default parameters + let commandDefaultDefinition: CommandJson | {} = {}; + switch (command.commandKind) { + case RushConstants.phasedCommandKind: + case RushConstants.bulkCommandKind: { + switch (command.name) { + case RushConstants.buildCommandName: { + commandDefaultDefinition = DEFAULT_BUILD_COMMAND_JSON; + break; + } + + case RushConstants.rebuildCommandName: { + commandDefaultDefinition = DEFAULT_REBUILD_COMMAND_JSON; + break; + } + } + break; + } + } + + // Merge the default parameters into the repo-specified parameters + commandLineJson.commands[i] = { + ...commandDefaultDefinition, + ...command + }; + } + } +} diff --git a/libraries/rush-lib/src/api/CommonVersionsConfiguration.ts b/libraries/rush-lib/src/api/CommonVersionsConfiguration.ts index b93cd7b59cf..42b74f7e961 100644 --- a/libraries/rush-lib/src/api/CommonVersionsConfiguration.ts +++ b/libraries/rush-lib/src/api/CommonVersionsConfiguration.ts @@ -57,14 +57,14 @@ interface ICommonVersionsJson { ensureConsistentVersions?: boolean; } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * Use this class to load and save the "common/config/rush/common-versions.json" config file. * This config file stores dependency version information that affects all projects in the repo. * @public */ export class CommonVersionsConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - private _preferredVersions: ProtectableMap; private _allowedAlternativeVersions: ProtectableMap; private _modified: boolean = false; @@ -170,14 +170,8 @@ export class CommonVersionsConfiguration { if (commonVersionsJson) { try { - CommonVersionsConfiguration._deserializeTable( - this.preferredVersions, - commonVersionsJson.preferredVersions - ); - CommonVersionsConfiguration._deserializeTable( - this.allowedAlternativeVersions, - commonVersionsJson.allowedAlternativeVersions - ); + _deserializeTable(this.preferredVersions, commonVersionsJson.preferredVersions); + _deserializeTable(this.allowedAlternativeVersions, commonVersionsJson.allowedAlternativeVersions); } catch (e) { throw new Error(`Error loading "${path.basename(filePath)}": ${(e as Error).message}`); } @@ -194,7 +188,7 @@ export class CommonVersionsConfiguration { ): CommonVersionsConfiguration { let commonVersionsJson: ICommonVersionsJson | undefined = undefined; try { - commonVersionsJson = JsonFile.loadAndValidate(jsonFilePath, CommonVersionsConfiguration._jsonSchema); + commonVersionsJson = JsonFile.loadAndValidate(jsonFilePath, _jsonSchema); } catch (error) { if (!FileSystem.isNotExistError(error)) { throw error; @@ -214,10 +208,7 @@ export class CommonVersionsConfiguration { ): Promise { let commonVersionsJson: ICommonVersionsJson | undefined = undefined; try { - commonVersionsJson = await JsonFile.loadAndValidateAsync( - jsonFilePath, - CommonVersionsConfiguration._jsonSchema - ); + commonVersionsJson = await JsonFile.loadAndValidateAsync(jsonFilePath, _jsonSchema); } catch (error) { if (!FileSystem.isNotExistError(error)) { throw error; @@ -227,29 +218,6 @@ export class CommonVersionsConfiguration { return new CommonVersionsConfiguration(commonVersionsJson, jsonFilePath, rushConfiguration); } - private static _deserializeTable( - map: Map, - object: { [key: string]: TValue } | undefined - ): void { - if (object) { - for (const [key, value] of Object.entries(object)) { - map.set(key, value); - } - } - } - - private static _serializeTable(map: Map): { [key: string]: TValue } { - const table: { [key: string]: TValue } = {}; - - const keys: string[] = [...map.keys()]; - keys.sort(); - for (const key of keys) { - table[key] = map.get(key)!; - } - - return table; - } - /** * Get a sha1 hash of the preferred versions. */ @@ -334,12 +302,12 @@ export class CommonVersionsConfiguration { private _serialize(): ICommonVersionsJson { let preferredVersions: ICommonVersionsJsonVersionMap | undefined; if (this._preferredVersions.size) { - preferredVersions = CommonVersionsConfiguration._serializeTable(this.preferredVersions); + preferredVersions = _serializeTable(this.preferredVersions); } let allowedAlternativeVersions: ICommonVersionsJsonVersionsMap | undefined; if (this._allowedAlternativeVersions.size) { - allowedAlternativeVersions = CommonVersionsConfiguration._serializeTable( + allowedAlternativeVersions = _serializeTable( this.allowedAlternativeVersions ) as ICommonVersionsJsonVersionsMap; } @@ -356,3 +324,26 @@ export class CommonVersionsConfiguration { return result; } } + +function _deserializeTable( + map: Map, + object: { [key: string]: TValue } | undefined +): void { + if (object) { + for (const [key, value] of Object.entries(object)) { + map.set(key, value); + } + } +} + +function _serializeTable(map: Map): { [key: string]: TValue } { + const table: { [key: string]: TValue } = {}; + + const keys: string[] = [...map.keys()]; + keys.sort(); + for (const key of keys) { + table[key] = map.get(key)!; + } + + return table; +} diff --git a/libraries/rush-lib/src/api/CustomTipsConfiguration.ts b/libraries/rush-lib/src/api/CustomTipsConfiguration.ts index c45dd2aa86b..8bc12a439e8 100644 --- a/libraries/rush-lib/src/api/CustomTipsConfiguration.ts +++ b/libraries/rush-lib/src/api/CustomTipsConfiguration.ts @@ -223,6 +223,8 @@ export const PNPM_CUSTOM_TIPS: Readonly; /** @@ -263,7 +263,7 @@ export class CustomTipsConfiguration { let configuration: ICustomTipsJson | undefined; try { - configuration = JsonFile.loadAndValidate(configFilePath, CustomTipsConfiguration._jsonSchema); + configuration = JsonFile.loadAndValidate(configFilePath, _jsonSchema); } catch (e) { if (!FileSystem.isNotExistError(e)) { throw e; diff --git a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts index de8c8d2d2ff..58cdea5a062 100644 --- a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts +++ b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts @@ -255,66 +255,66 @@ export const EnvironmentVariableNames = { RUSH_QUIET_MODE: 'RUSH_QUIET_MODE' } as const; -/** - * Provides Rush-specific environment variable data. All Rush environment variables must start with "RUSH_". This class - * is designed to be used by RushConfiguration. - * @beta - * - * @remarks - * Initialize will throw if any unknown parameters are present. - */ -export class EnvironmentConfiguration { - private static _hasBeenValidated: boolean = false; +let _hasBeenValidated: boolean = false; - private static _rushTempFolderOverride: string | undefined; +let _rushTempFolderOverride: string | undefined; - private static _absoluteSymlinks: boolean = false; +let _absoluteSymlinks: boolean = false; - private static _allowUnsupportedNodeVersion: boolean = false; +let _allowUnsupportedNodeVersion: boolean = false; - private static _allowWarningsInSuccessfulBuild: boolean = false; +let _allowWarningsInSuccessfulBuild: boolean = false; - private static _pnpmStorePathOverride: string | undefined; +let _pnpmStorePathOverride: string | undefined; - private static _pnpmVerifyStoreIntegrity: boolean | undefined; +let _pnpmVerifyStoreIntegrity: boolean | undefined; - private static _rushGlobalFolderOverride: string | undefined; +let _rushGlobalFolderOverride: string | undefined; - private static _buildCacheCredential: string | undefined; +let _buildCacheCredential: string | undefined; - private static _buildCacheEnabled: boolean | undefined; +let _buildCacheEnabled: boolean | undefined; - private static _buildCacheWriteAllowed: boolean | undefined; +let _buildCacheWriteAllowed: boolean | undefined; - private static _buildCacheOverrideJson: string | undefined; +let _buildCacheOverrideJson: string | undefined; - private static _buildCacheOverrideJsonFilePath: string | undefined; +let _buildCacheOverrideJsonFilePath: string | undefined; - private static _cobuildContextId: string | undefined; +let _cobuildContextId: string | undefined; - private static _cobuildRunnerId: string | undefined; +let _cobuildRunnerId: string | undefined; - private static _cobuildLeafProjectLogOnlyAllowed: boolean | undefined; +let _cobuildLeafProjectLogOnlyAllowed: boolean | undefined; - private static _gitBinaryPath: string | undefined; +let _gitBinaryPath: string | undefined; - private static _tarBinaryPath: string | undefined; +let _tarBinaryPath: string | undefined; - private static _quietMode: boolean = false; +let _quietMode: boolean = false; +/** + * Provides Rush-specific environment variable data. All Rush environment variables must start with "RUSH_". This class + * is designed to be used by RushConfiguration. + * @beta + * + * @remarks + * Initialize will throw if any unknown parameters are present. + */ +export class EnvironmentConfiguration { /** * If true, the environment configuration has been validated and initialized. */ public static get hasBeenValidated(): boolean { - return EnvironmentConfiguration._hasBeenValidated; + return _hasBeenValidated; } /** * An override for the common/temp folder path. */ public static get rushTempFolderOverride(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._rushTempFolderOverride; + _ensureValidated(); + return _rushTempFolderOverride; } /** @@ -322,8 +322,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_ABSOLUTE_SYMLINKS} */ public static get absoluteSymlinks(): boolean { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._absoluteSymlinks; + _ensureValidated(); + return _absoluteSymlinks; } /** @@ -334,8 +334,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_ALLOW_UNSUPPORTED_NODEJS}. */ public static get allowUnsupportedNodeVersion(): boolean { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._allowUnsupportedNodeVersion; + _ensureValidated(); + return _allowUnsupportedNodeVersion; } /** @@ -344,8 +344,8 @@ export class EnvironmentConfiguration { * or `0` to disallow them. (See the comments in the command-line.json file for more information). */ public static get allowWarningsInSuccessfulBuild(): boolean { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._allowWarningsInSuccessfulBuild; + _ensureValidated(); + return _allowWarningsInSuccessfulBuild; } /** @@ -353,8 +353,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} */ public static get pnpmStorePathOverride(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._pnpmStorePathOverride; + _ensureValidated(); + return _pnpmStorePathOverride; } /** @@ -362,8 +362,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY} */ public static get pnpmVerifyStoreIntegrity(): boolean | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._pnpmVerifyStoreIntegrity; + _ensureValidated(); + return _pnpmVerifyStoreIntegrity; } /** @@ -371,8 +371,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_GLOBAL_FOLDER} */ public static get rushGlobalFolderOverride(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._rushGlobalFolderOverride; + _ensureValidated(); + return _rushGlobalFolderOverride; } /** @@ -380,8 +380,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_BUILD_CACHE_CREDENTIAL} */ public static get buildCacheCredential(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._buildCacheCredential; + _ensureValidated(); + return _buildCacheCredential; } /** @@ -389,8 +389,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_BUILD_CACHE_ENABLED} */ public static get buildCacheEnabled(): boolean | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._buildCacheEnabled; + _ensureValidated(); + return _buildCacheEnabled; } /** @@ -398,8 +398,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_BUILD_CACHE_WRITE_ALLOWED} */ public static get buildCacheWriteAllowed(): boolean | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._buildCacheWriteAllowed; + _ensureValidated(); + return _buildCacheWriteAllowed; } /** @@ -407,8 +407,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON} */ public static get buildCacheOverrideJson(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._buildCacheOverrideJson; + _ensureValidated(); + return _buildCacheOverrideJson; } /** @@ -416,8 +416,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON_FILE_PATH} */ public static get buildCacheOverrideJsonFilePath(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._buildCacheOverrideJsonFilePath; + _ensureValidated(); + return _buildCacheOverrideJsonFilePath; } /** @@ -425,8 +425,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_COBUILD_CONTEXT_ID} */ public static get cobuildContextId(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._cobuildContextId; + _ensureValidated(); + return _cobuildContextId; } /** @@ -434,8 +434,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_COBUILD_RUNNER_ID} */ public static get cobuildRunnerId(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._cobuildRunnerId; + _ensureValidated(); + return _cobuildRunnerId; } /** @@ -443,8 +443,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_COBUILD_LEAF_PROJECT_LOG_ONLY_ALLOWED} */ public static get cobuildLeafProjectLogOnlyAllowed(): boolean | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._cobuildLeafProjectLogOnlyAllowed; + _ensureValidated(); + return _cobuildLeafProjectLogOnlyAllowed; } /** @@ -452,8 +452,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_GIT_BINARY_PATH} */ public static get gitBinaryPath(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._gitBinaryPath; + _ensureValidated(); + return _gitBinaryPath; } /** @@ -461,8 +461,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_TAR_BINARY_PATH} */ public static get tarBinaryPath(): string | undefined { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._tarBinaryPath; + _ensureValidated(); + return _tarBinaryPath; } /** @@ -470,8 +470,8 @@ export class EnvironmentConfiguration { * See {@link EnvironmentVariableNames.RUSH_QUIET_MODE} */ public static get quietMode(): boolean { - EnvironmentConfiguration._ensureValidated(); - return EnvironmentConfiguration._quietMode; + _ensureValidated(); + return _quietMode; } /** @@ -483,8 +483,7 @@ export class EnvironmentConfiguration { public static _getRushGlobalFolderOverride(processEnv: IEnvironment): string | undefined { const value: string | undefined = processEnv[EnvironmentVariableNames.RUSH_GLOBAL_FOLDER]; if (value) { - const normalizedValue: string | undefined = - EnvironmentConfiguration._normalizeDeepestParentFolderPath(value); + const normalizedValue: string | undefined = _normalizeDeepestParentFolderPath(value); return normalizedValue; } } @@ -503,15 +502,15 @@ export class EnvironmentConfiguration { const normalizedEnvVarName: string = IS_WINDOWS ? envVarName.toUpperCase() : envVarName; switch (normalizedEnvVarName) { case EnvironmentVariableNames.RUSH_TEMP_FOLDER: { - EnvironmentConfiguration._rushTempFolderOverride = + _rushTempFolderOverride = value && !options.doNotNormalizePaths - ? EnvironmentConfiguration._normalizeDeepestParentFolderPath(value) || value + ? _normalizeDeepestParentFolderPath(value) || value : value; break; } case EnvironmentVariableNames.RUSH_ABSOLUTE_SYMLINKS: { - EnvironmentConfiguration._absoluteSymlinks = + _absoluteSymlinks = EnvironmentConfiguration.parseBooleanEnvironmentVariable( EnvironmentVariableNames.RUSH_ABSOLUTE_SYMLINKS, value @@ -523,9 +522,9 @@ export class EnvironmentConfiguration { if (value === 'true' || value === 'false') { // Small, undocumented acceptance of old "true" and "false" values for // users of RUSH_ALLOW_UNSUPPORTED_NODEJS in rush pre-v5.46. - EnvironmentConfiguration._allowUnsupportedNodeVersion = value === 'true'; + _allowUnsupportedNodeVersion = value === 'true'; } else { - EnvironmentConfiguration._allowUnsupportedNodeVersion = + _allowUnsupportedNodeVersion = EnvironmentConfiguration.parseBooleanEnvironmentVariable( EnvironmentVariableNames.RUSH_ALLOW_UNSUPPORTED_NODEJS, value @@ -535,7 +534,7 @@ export class EnvironmentConfiguration { } case EnvironmentVariableNames.RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD: { - EnvironmentConfiguration._allowWarningsInSuccessfulBuild = + _allowWarningsInSuccessfulBuild = EnvironmentConfiguration.parseBooleanEnvironmentVariable( EnvironmentVariableNames.RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD, value @@ -544,16 +543,15 @@ export class EnvironmentConfiguration { } case EnvironmentVariableNames.RUSH_PNPM_STORE_PATH: { - EnvironmentConfiguration._pnpmStorePathOverride = + _pnpmStorePathOverride = value && !options.doNotNormalizePaths - ? EnvironmentConfiguration._normalizeDeepestParentFolderPath(value) || value + ? _normalizeDeepestParentFolderPath(value) || value : value; break; } case EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY: { - EnvironmentConfiguration._pnpmVerifyStoreIntegrity = - value === '1' ? true : value === '0' ? false : undefined; + _pnpmVerifyStoreIntegrity = value === '1' ? true : value === '0' ? false : undefined; break; } @@ -563,73 +561,70 @@ export class EnvironmentConfiguration { } case EnvironmentVariableNames.RUSH_BUILD_CACHE_CREDENTIAL: { - EnvironmentConfiguration._buildCacheCredential = value; + _buildCacheCredential = value; break; } case EnvironmentVariableNames.RUSH_BUILD_CACHE_ENABLED: { - EnvironmentConfiguration._buildCacheEnabled = - EnvironmentConfiguration.parseBooleanEnvironmentVariable( - EnvironmentVariableNames.RUSH_BUILD_CACHE_ENABLED, - value - ); + _buildCacheEnabled = EnvironmentConfiguration.parseBooleanEnvironmentVariable( + EnvironmentVariableNames.RUSH_BUILD_CACHE_ENABLED, + value + ); break; } case EnvironmentVariableNames.RUSH_BUILD_CACHE_WRITE_ALLOWED: { - EnvironmentConfiguration._buildCacheWriteAllowed = - EnvironmentConfiguration.parseBooleanEnvironmentVariable( - EnvironmentVariableNames.RUSH_BUILD_CACHE_WRITE_ALLOWED, - value - ); + _buildCacheWriteAllowed = EnvironmentConfiguration.parseBooleanEnvironmentVariable( + EnvironmentVariableNames.RUSH_BUILD_CACHE_WRITE_ALLOWED, + value + ); break; } case EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON: { - EnvironmentConfiguration._buildCacheOverrideJson = value; + _buildCacheOverrideJson = value; break; } case EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON_FILE_PATH: { - EnvironmentConfiguration._buildCacheOverrideJsonFilePath = value; + _buildCacheOverrideJsonFilePath = value; break; } case EnvironmentVariableNames.RUSH_COBUILD_CONTEXT_ID: { - EnvironmentConfiguration._cobuildContextId = value; + _cobuildContextId = value; break; } case EnvironmentVariableNames.RUSH_COBUILD_RUNNER_ID: { - EnvironmentConfiguration._cobuildRunnerId = value; + _cobuildRunnerId = value; break; } case EnvironmentVariableNames.RUSH_COBUILD_LEAF_PROJECT_LOG_ONLY_ALLOWED: { - EnvironmentConfiguration._cobuildLeafProjectLogOnlyAllowed = - EnvironmentConfiguration.parseBooleanEnvironmentVariable( - EnvironmentVariableNames.RUSH_COBUILD_LEAF_PROJECT_LOG_ONLY_ALLOWED, - value - ); + _cobuildLeafProjectLogOnlyAllowed = EnvironmentConfiguration.parseBooleanEnvironmentVariable( + EnvironmentVariableNames.RUSH_COBUILD_LEAF_PROJECT_LOG_ONLY_ALLOWED, + value + ); break; } case EnvironmentVariableNames.RUSH_GIT_BINARY_PATH: { - EnvironmentConfiguration._gitBinaryPath = value; + _gitBinaryPath = value; break; } case EnvironmentVariableNames.RUSH_TAR_BINARY_PATH: { - EnvironmentConfiguration._tarBinaryPath = value; + _tarBinaryPath = value; break; } case EnvironmentVariableNames.RUSH_QUIET_MODE: { // Accept both "true"/"false" string values and the standard "1"/"0" values if (value === 'true' || value === 'false') { - EnvironmentConfiguration._quietMode = value === 'true'; + _quietMode = value === 'true'; } else { - EnvironmentConfiguration._quietMode = + _quietMode = EnvironmentConfiguration.parseBooleanEnvironmentVariable( EnvironmentVariableNames.RUSH_QUIET_MODE, value @@ -670,10 +665,7 @@ export class EnvironmentConfiguration { ); } - if ( - EnvironmentConfiguration._buildCacheOverrideJsonFilePath && - EnvironmentConfiguration._buildCacheOverrideJson - ) { + if (_buildCacheOverrideJsonFilePath && _buildCacheOverrideJson) { throw new Error( `Environment variable ${EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON_FILE_PATH} and ` + `${EnvironmentVariableNames.RUSH_BUILD_CACHE_OVERRIDE_JSON} are mutually exclusive. ` + @@ -682,27 +674,21 @@ export class EnvironmentConfiguration { } // See doc comment for EnvironmentConfiguration._getRushGlobalFolderOverride(). - EnvironmentConfiguration._rushGlobalFolderOverride = - EnvironmentConfiguration._getRushGlobalFolderOverride(process.env); + _rushGlobalFolderOverride = EnvironmentConfiguration._getRushGlobalFolderOverride(process.env); - EnvironmentConfiguration._hasBeenValidated = true; + _hasBeenValidated = true; } /** * Resets EnvironmentConfiguration into an un-initialized state. */ public static reset(): void { - EnvironmentConfiguration._rushTempFolderOverride = undefined; - EnvironmentConfiguration._quietMode = false; - EnvironmentConfiguration._gitBinaryPath = undefined; - EnvironmentConfiguration._tarBinaryPath = undefined; - EnvironmentConfiguration._hasBeenValidated = false; - } - - private static _ensureValidated(): void { - if (!EnvironmentConfiguration._hasBeenValidated) { - EnvironmentConfiguration.validate(); - } + _rushTempFolderOverride = undefined; + _pnpmStorePathOverride = undefined; + _quietMode = false; + _gitBinaryPath = undefined; + _tarBinaryPath = undefined; + _hasBeenValidated = false; } public static parseBooleanEnvironmentVariable( @@ -721,49 +707,53 @@ export class EnvironmentConfiguration { ); } } +} - /** - * Given a path to a folder (that may or may not exist), normalize the path, including casing, - * to the first existing parent folder in the path. - * - * If no existing path can be found (for example, if the root is a volume that doesn't exist), - * this function returns undefined. - * - * @example - * If the following path exists on disk: `C:\Folder1\folder2\` - * _normalizeFirstExistingFolderPath('c:\\folder1\\folder2\\temp\\subfolder') - * returns 'C:\\Folder1\\folder2\\temp\\subfolder' - */ - private static _normalizeDeepestParentFolderPath(folderPath: string): string | undefined { - folderPath = path.normalize(folderPath); - const endsWithSlash: boolean = folderPath.charAt(folderPath.length - 1) === path.sep; - const parsedPath: path.ParsedPath = path.parse(folderPath); - const pathRoot: string = parsedPath.root; - const pathWithoutRoot: string = parsedPath.dir.substr(pathRoot.length); - const pathParts: string[] = [...pathWithoutRoot.split(path.sep), parsedPath.name].filter( - (part) => !!part - ); - - // Starting with all path sections, and eliminating one from the end during each loop iteration, - // run trueCasePathSync. If trueCasePathSync returns without exception, we've found a subset - // of the path that exists and we've now gotten the correct casing. - // - // Once we've found a parent folder that exists, append the path sections that didn't exist. - for (let i: number = pathParts.length; i >= 0; i--) { - const constructedPath: string = path.join(pathRoot, ...pathParts.slice(0, i)); - try { - const normalizedConstructedPath: string = trueCasePathSync(constructedPath); - const result: string = path.join(normalizedConstructedPath, ...pathParts.slice(i)); - if (endsWithSlash) { - return `${result}${path.sep}`; - } else { - return result; - } - } catch (e) { - // This path doesn't exist, continue to the next subpath +function _ensureValidated(): void { + if (!_hasBeenValidated) { + EnvironmentConfiguration.validate(); + } +} + +/** + * Given a path to a folder (that may or may not exist), normalize the path, including casing, + * to the first existing parent folder in the path. + * + * If no existing path can be found (for example, if the root is a volume that doesn't exist), + * this function returns undefined. + * + * @example + * If the following path exists on disk: `C:\Folder1\folder2\` + * _normalizeFirstExistingFolderPath('c:\\folder1\\folder2\\temp\\subfolder') + * returns 'C:\\Folder1\\folder2\\temp\\subfolder' + */ +function _normalizeDeepestParentFolderPath(folderPath: string): string | undefined { + folderPath = path.normalize(folderPath); + const endsWithSlash: boolean = folderPath.charAt(folderPath.length - 1) === path.sep; + const parsedPath: path.ParsedPath = path.parse(folderPath); + const pathRoot: string = parsedPath.root; + const pathWithoutRoot: string = parsedPath.dir.substr(pathRoot.length); + const pathParts: string[] = [...pathWithoutRoot.split(path.sep), parsedPath.name].filter((part) => !!part); + + // Starting with all path sections, and eliminating one from the end during each loop iteration, + // run trueCasePathSync. If trueCasePathSync returns without exception, we've found a subset + // of the path that exists and we've now gotten the correct casing. + // + // Once we've found a parent folder that exists, append the path sections that didn't exist. + for (let i: number = pathParts.length; i >= 0; i--) { + const constructedPath: string = path.join(pathRoot, ...pathParts.slice(0, i)); + try { + const normalizedConstructedPath: string = trueCasePathSync(constructedPath); + const result: string = path.join(normalizedConstructedPath, ...pathParts.slice(i)); + if (endsWithSlash) { + return `${result}${path.sep}`; + } else { + return result; } + } catch (e) { + // This path doesn't exist, continue to the next subpath } - - return undefined; } + + return undefined; } diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index af1187d1598..64e06354047 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -56,15 +56,15 @@ export interface ILaunchOptions { builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; } +let _rushLibPackageJsonCache: IPackageJson | undefined = undefined; +let _rushLibPackageFolderCache: string | undefined = undefined; + /** * General operations for the Rush engine. * * @public */ export class Rush { - private static __rushLibPackageJson: IPackageJson | undefined = undefined; - private static __rushLibPackageFolder: string | undefined = undefined; - /** * This API is used by the `@microsoft/rush` front end to launch the "rush" command-line. * Third-party tools should not use this API. Instead, they should execute the "rush" binary @@ -77,7 +77,7 @@ export class Rush { * Even though this API isn't documented, it is still supported for legacy compatibility. */ public static launch(launcherVersion: string, options: ILaunchOptions): void { - options = Rush._normalizeLaunchOptions(options); + options = _normalizeLaunchOptions(options); if (!RushCommandLineParser.shouldRestrictConsoleOutput()) { RushStartupBanner.logBanner(Rush.version, options.isManaged); @@ -89,7 +89,7 @@ export class Rush { return; } - Rush._assignRushInvokedFolder(); + _assignRushInvokedFolder(); const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, builtInPluginConfigurations: options.builtInPluginConfigurations @@ -105,8 +105,8 @@ export class Rush { * and start a new Node.js process. */ public static launchRushX(launcherVersion: string, options: ILaunchOptions): void { - options = Rush._normalizeLaunchOptions(options); - Rush._assignRushInvokedFolder(); + options = _normalizeLaunchOptions(options); + _assignRushInvokedFolder(); // eslint-disable-next-line no-console RushXCommandLine.launchRushXAsync(launcherVersion, options).catch(console.error); // CommandLineParser.executeAsync() should never reject the promise } @@ -117,7 +117,7 @@ export class Rush { * and start a new Node.js process. */ public static launchRushPnpm(launcherVersion: string, options: ILaunchOptions): void { - Rush._assignRushInvokedFolder(); + _assignRushInvokedFolder(); RushPnpmCommandLine.launch(launcherVersion, { ...options }); } @@ -133,25 +133,13 @@ export class Rush { * @internal */ public static get _rushLibPackageJson(): IPackageJson { - Rush._ensureOwnPackageJsonIsLoaded(); - return Rush.__rushLibPackageJson!; + _ensureOwnPackageJsonIsLoaded(); + return _rushLibPackageJsonCache!; } public static get _rushLibPackageFolder(): string { - Rush._ensureOwnPackageJsonIsLoaded(); - return Rush.__rushLibPackageFolder!; - } - - private static _ensureOwnPackageJsonIsLoaded(): void { - if (!Rush.__rushLibPackageJson) { - const packageJsonFilePath: string | undefined = - PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); - if (!packageJsonFilePath) { - throw new InternalError('Unable to locate the package.json file for this module'); - } - Rush.__rushLibPackageFolder = path.dirname(packageJsonFilePath); - Rush.__rushLibPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); - } + _ensureOwnPackageJsonIsLoaded(); + return _rushLibPackageFolderCache!; } /** @@ -165,16 +153,29 @@ export class Rush { * The natural time to do that refactoring is when we rework `Utilities.executeCommand()` to use * `Executable.spawn()` or rushell. */ - private static _assignRushInvokedFolder(): void { - process.env[EnvironmentVariableNames.RUSH_INVOKED_FOLDER] = process.cwd(); - } +} - /** - * This function normalizes legacy options to the current {@link ILaunchOptions} object. - */ - private static _normalizeLaunchOptions(arg: ILaunchOptions): ILaunchOptions { - return typeof arg === 'boolean' - ? { isManaged: arg } // In older versions of Rush, this the `launch` functions took a boolean arg for "isManaged" - : arg; +function _ensureOwnPackageJsonIsLoaded(): void { + if (!_rushLibPackageJsonCache) { + const packageJsonFilePath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!packageJsonFilePath) { + throw new InternalError('Unable to locate the package.json file for this module'); + } + _rushLibPackageFolderCache = path.dirname(packageJsonFilePath); + _rushLibPackageJsonCache = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); } } + +function _assignRushInvokedFolder(): void { + process.env[EnvironmentVariableNames.RUSH_INVOKED_FOLDER] = process.cwd(); +} + +/** + * This function normalizes legacy options to the current {@link ILaunchOptions} object. + */ +function _normalizeLaunchOptions(arg: ILaunchOptions): ILaunchOptions { + return typeof arg === 'boolean' + ? { isManaged: arg } // In older versions of Rush, this the `launch` functions took a boolean arg for "isManaged" + : arg; +} diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index c1398ea0622..527ec92be93 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -219,14 +219,14 @@ export interface ITryFindRushJsonLocationOptions { startingFolder?: string; // Defaults to cwd } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * This represents the Rush configuration for a repository, based on the "rush.json" * configuration file. * @public */ export class RushConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - private readonly _pathTrees: Map>; /** @@ -760,7 +760,7 @@ export class RushConfiguration { ) ); - RushConfiguration._validateCommonRushConfigFolder( + _validateCommonRushConfigFolder( this.commonRushConfigFolder, this.packageManagerWrapper, this.experimentsConfiguration, @@ -933,10 +933,7 @@ export class RushConfiguration { const usedTempNames: Set = new Set(); for (let i: number = 0, len: number = sortedProjectJsons.length; i < len; i++) { const projectJson: IRushConfigurationProjectJson = sortedProjectJsons[i]; - const tempProjectName: string | undefined = RushConfiguration._generateTempNameForProject( - projectJson, - usedTempNames - ); + const tempProjectName: string | undefined = _generateTempNameForProject(projectJson, usedTempNames); let subspace: Subspace | undefined = undefined; if (this.subspacesFeatureEnabled) { @@ -1043,7 +1040,7 @@ export class RushConfiguration { } } - RushConfiguration._jsonSchema.validateObject(rushConfigurationJson, resolvedRushJsonFilename); + _jsonSchema.validateObject(rushConfigurationJson, resolvedRushJsonFilename); return new RushConfiguration(rushConfigurationJson, resolvedRushJsonFilename); } @@ -1105,118 +1102,6 @@ export class RushConfiguration { return undefined; } - /** - * This generates the unique names that are used to create temporary projects - * in the Rush common folder. - * NOTE: sortedProjectJsons is sorted by the caller. - */ - private static _generateTempNameForProject( - projectJson: IRushConfigurationProjectJson, - usedTempNames: Set - ): string { - // If the name is "@ms/MyProject", extract the "MyProject" part - const unscopedName: string = PackageNameParsers.permissive.getUnscopedName(projectJson.packageName); - - // Generate a unique like name "@rush-temp/MyProject", or "@rush-temp/MyProject-2" if - // there is a naming conflict - let counter: number = 0; - let tempProjectName: string = `${RushConstants.rushTempNpmScope}/${unscopedName}`; - while (usedTempNames.has(tempProjectName)) { - ++counter; - tempProjectName = `${RushConstants.rushTempNpmScope}/${unscopedName}-${counter}`; - } - usedTempNames.add(tempProjectName); - - return tempProjectName; - } - - /** - * If someone adds a config file in the "common/rush/config" folder, it would be a bad - * experience for Rush to silently ignore their file simply because they misspelled the - * filename, or maybe it's an old format that's no longer supported. The - * _validateCommonRushConfigFolder() function makes sure that this folder only contains - * recognized config files. - */ - private static _validateCommonRushConfigFolder( - commonRushConfigFolder: string, - packageManagerWrapper: PackageManager, - experiments: ExperimentsConfiguration, - subspacesFeatureEnabled: boolean - ): void { - if (!FileSystem.exists(commonRushConfigFolder)) { - // eslint-disable-next-line no-console - console.log(`Creating folder: ${commonRushConfigFolder}`); - FileSystem.ensureFolder(commonRushConfigFolder); - return; - } - - for (const filename of FileSystem.readFolderItemNames(commonRushConfigFolder)) { - // Ignore things that aren't actual files - const stat: FileSystemStats = FileSystem.getLinkStatistics(path.join(commonRushConfigFolder, filename)); - if (!stat.isFile() && !stat.isSymbolicLink()) { - continue; - } - - // Ignore harmless file extensions - const fileExtension: string = path.extname(filename); - if (['.bak', '.disabled', '.md', '.old', '.orig'].indexOf(fileExtension) >= 0) { - continue; - } - - // Check if there are prohibited files when subspaces is enabled - if (subspacesFeatureEnabled) { - if (filename === RushConstants.pnpmfileV6Filename || filename === RushConstants.pnpmfileV1Filename) { - throw new Error( - 'When the subspaces feature is enabled, a separate lockfile is stored in each subspace folder. ' + - `To avoid confusion, remove this file: ${commonRushConfigFolder}/${filename}` - ); - } - } - - // Ignore hidden files such as ".DS_Store" - if (filename.startsWith('.')) { - continue; - } - - if (filename.startsWith('deploy-') && fileExtension === '.json') { - // Ignore "rush deploy" files, which use the naming pattern "deploy-.json". - continue; - } - - const knownSet: Set = new Set(knownRushConfigFilenames.map((x) => x.toUpperCase())); - - // Add the shrinkwrap filename for the package manager to the known set. - knownSet.add(packageManagerWrapper.shrinkwrapFilename.toUpperCase()); - - // If the package manager is pnpm, then also add the pnpm file to the known set. - if (packageManagerWrapper.packageManager === 'pnpm') { - const pnpmPackageManager: PnpmPackageManager = packageManagerWrapper as PnpmPackageManager; - knownSet.add(pnpmPackageManager.pnpmfileFilename.toUpperCase()); - } - - // Is the filename something we know? If not, report an error. - if (!knownSet.has(filename.toUpperCase())) { - throw new Error( - `An unrecognized file "${filename}" was found in the Rush config folder:` + - ` ${commonRushConfigFolder}` - ); - } - } - - const pinnedVersionsFilename: string = path.join( - commonRushConfigFolder, - RushConstants.pinnedVersionsFilename - ); - if (FileSystem.exists(pinnedVersionsFilename)) { - throw new Error( - 'The "pinned-versions.json" config file is no longer supported;' + - ' please move your settings to the "preferredVersions" field of a "common-versions.json" config file.' + - ` (See the ${RushConstants.rushWebSiteUrl} documentation for details.)\n\n` + - pinnedVersionsFilename - ); - } - } - /** * The fully resolved path for the "autoinstallers" folder. * Example: `C:\MyRepo\common\autoinstallers` @@ -1599,3 +1484,115 @@ export class RushConfiguration { } } } + +/** + * This generates the unique names that are used to create temporary projects + * in the Rush common folder. + * NOTE: sortedProjectJsons is sorted by the caller. + */ +function _generateTempNameForProject( + projectJson: IRushConfigurationProjectJson, + usedTempNames: Set +): string { + // If the name is "@ms/MyProject", extract the "MyProject" part + const unscopedName: string = PackageNameParsers.permissive.getUnscopedName(projectJson.packageName); + + // Generate a unique like name "@rush-temp/MyProject", or "@rush-temp/MyProject-2" if + // there is a naming conflict + let counter: number = 0; + let tempProjectName: string = `${RushConstants.rushTempNpmScope}/${unscopedName}`; + while (usedTempNames.has(tempProjectName)) { + ++counter; + tempProjectName = `${RushConstants.rushTempNpmScope}/${unscopedName}-${counter}`; + } + usedTempNames.add(tempProjectName); + + return tempProjectName; +} + +/** + * If someone adds a config file in the "common/rush/config" folder, it would be a bad + * experience for Rush to silently ignore their file simply because they misspelled the + * filename, or maybe it's an old format that's no longer supported. The + * _validateCommonRushConfigFolder() function makes sure that this folder only contains + * recognized config files. + */ +function _validateCommonRushConfigFolder( + commonRushConfigFolder: string, + packageManagerWrapper: PackageManager, + experiments: ExperimentsConfiguration, + subspacesFeatureEnabled: boolean +): void { + if (!FileSystem.exists(commonRushConfigFolder)) { + // eslint-disable-next-line no-console + console.log(`Creating folder: ${commonRushConfigFolder}`); + FileSystem.ensureFolder(commonRushConfigFolder); + return; + } + + for (const filename of FileSystem.readFolderItemNames(commonRushConfigFolder)) { + // Ignore things that aren't actual files + const stat: FileSystemStats = FileSystem.getLinkStatistics(path.join(commonRushConfigFolder, filename)); + if (!stat.isFile() && !stat.isSymbolicLink()) { + continue; + } + + // Ignore harmless file extensions + const fileExtension: string = path.extname(filename); + if (['.bak', '.disabled', '.md', '.old', '.orig'].indexOf(fileExtension) >= 0) { + continue; + } + + // Check if there are prohibited files when subspaces is enabled + if (subspacesFeatureEnabled) { + if (filename === RushConstants.pnpmfileV6Filename || filename === RushConstants.pnpmfileV1Filename) { + throw new Error( + 'When the subspaces feature is enabled, a separate lockfile is stored in each subspace folder. ' + + `To avoid confusion, remove this file: ${commonRushConfigFolder}/${filename}` + ); + } + } + + // Ignore hidden files such as ".DS_Store" + if (filename.startsWith('.')) { + continue; + } + + if (filename.startsWith('deploy-') && fileExtension === '.json') { + // Ignore "rush deploy" files, which use the naming pattern "deploy-.json". + continue; + } + + const knownSet: Set = new Set(knownRushConfigFilenames.map((x) => x.toUpperCase())); + + // Add the shrinkwrap filename for the package manager to the known set. + knownSet.add(packageManagerWrapper.shrinkwrapFilename.toUpperCase()); + + // If the package manager is pnpm, then also add the pnpm file to the known set. + if (packageManagerWrapper.packageManager === 'pnpm') { + const pnpmPackageManager: PnpmPackageManager = packageManagerWrapper as PnpmPackageManager; + knownSet.add(pnpmPackageManager.pnpmfileFilename.toUpperCase()); + } + + // Is the filename something we know? If not, report an error. + if (!knownSet.has(filename.toUpperCase())) { + throw new Error( + `An unrecognized file "${filename}" was found in the Rush config folder:` + + ` ${commonRushConfigFolder}` + ); + } + } + + const pinnedVersionsFilename: string = path.join( + commonRushConfigFolder, + RushConstants.pinnedVersionsFilename + ); + if (FileSystem.exists(pinnedVersionsFilename)) { + throw new Error( + 'The "pinned-versions.json" config file is no longer supported;' + + ' please move your settings to the "preferredVersions" field of a "common-versions.json" config file.' + + ` (See the ${RushConstants.rushWebSiteUrl} documentation for details.)\n\n` + + pinnedVersionsFilename + ); + } +} diff --git a/libraries/rush-lib/src/api/RushPluginsConfiguration.ts b/libraries/rush-lib/src/api/RushPluginsConfiguration.ts index f8d194d0ab1..9d3908fb601 100644 --- a/libraries/rush-lib/src/api/RushPluginsConfiguration.ts +++ b/libraries/rush-lib/src/api/RushPluginsConfiguration.ts @@ -21,9 +21,9 @@ interface IRushPluginsConfigurationJson { plugins: IRushPluginConfiguration[]; } -export class RushPluginsConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); +export class RushPluginsConfiguration { private _jsonFilename: string; public readonly configuration: Readonly; @@ -35,7 +35,7 @@ export class RushPluginsConfiguration { }; if (FileSystem.exists(this._jsonFilename)) { - this.configuration = JsonFile.loadAndValidate(this._jsonFilename, RushPluginsConfiguration._jsonSchema); + this.configuration = JsonFile.loadAndValidate(this._jsonFilename, _jsonSchema); } } } diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index ab606aa1c15..50f71ce1785 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -276,6 +276,8 @@ const OLD_RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile = new Map(); + /** * Use this class to load the "config/rush-project.json" config file. * @@ -283,9 +285,6 @@ const OLD_RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile = - new Map(); - public readonly project: RushConfigurationProject; /** @@ -501,27 +500,28 @@ export class RushProjectConfiguration { terminal: ITerminal ): Promise { // false is a signal that the project config does not exist - const cacheEntry: RushProjectConfiguration | false | undefined = - RushProjectConfiguration._configCache.get(project); + const cacheEntry: RushProjectConfiguration | false | undefined = _configCache.get(project); if (cacheEntry !== undefined) { return cacheEntry || undefined; } - const rushProjectJson: IRushProjectJson | undefined = await this._tryLoadJsonForProjectAsync( + const rushProjectJson: IRushProjectJson | undefined = await _tryLoadJsonForProjectAsync( project, terminal ); if (rushProjectJson) { - const result: RushProjectConfiguration = RushProjectConfiguration._getRushProjectConfiguration( + const operationSettingsByOperationName: ReadonlyMap = + _getRushProjectConfiguration(project, rushProjectJson, terminal); + const result: RushProjectConfiguration = new RushProjectConfiguration( project, rushProjectJson, - terminal + operationSettingsByOperationName ); - RushProjectConfiguration._configCache.set(project, result); + _configCache.set(project, result); return result; } else { - RushProjectConfiguration._configCache.set(project, false); + _configCache.set(project, false); return undefined; } } @@ -538,7 +538,7 @@ export class RushProjectConfiguration { project: RushConfigurationProject, terminal: ITerminal ): Promise | undefined> { - const rushProjectJson: IRushProjectJson | undefined = await this._tryLoadJsonForProjectAsync( + const rushProjectJson: IRushProjectJson | undefined = await _tryLoadJsonForProjectAsync( project, terminal ); @@ -570,111 +570,115 @@ export class RushProjectConfiguration { return result; } +} - private static async _tryLoadJsonForProjectAsync( - project: RushConfigurationProject, - terminal: ITerminal - ): Promise { - const rigConfig: RigConfig = await RigConfig.loadForProjectFolderAsync({ - projectFolderPath: project.projectFolder - }); +async function _tryLoadJsonForProjectAsync( + project: RushConfigurationProject, + terminal: ITerminal +): Promise { + const rigConfig: RigConfig = await RigConfig.loadForProjectFolderAsync({ + projectFolderPath: project.projectFolder + }); + try { + return await RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + project.projectFolder, + rigConfig + ); + } catch (e1) { + // Detect if the project is using the old rush-project.json schema + let oldRushProjectJson: IOldRushProjectJson | undefined; try { - return await RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + oldRushProjectJson = await OLD_RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( terminal, project.projectFolder, rigConfig ); - } catch (e1) { - // Detect if the project is using the old rush-project.json schema - let oldRushProjectJson: IOldRushProjectJson | undefined; - try { - oldRushProjectJson = - await OLD_RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( - terminal, - project.projectFolder, - rigConfig - ); - } catch (e2) { - // Ignore - } + } catch (e2) { + // Ignore + } - if ( - oldRushProjectJson?.projectOutputFolderNames || - oldRushProjectJson?.phaseOptions || - oldRushProjectJson?.buildCacheOptions - ) { - throw new Error( - `The ${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath} file appears to be ` + - 'in an outdated format. Please see the UPGRADING.md notes for details. ' + - 'Quick link: https://rushjs.io/link/upgrading' - ); - } else { - throw e1; - } + if ( + oldRushProjectJson?.projectOutputFolderNames || + oldRushProjectJson?.phaseOptions || + oldRushProjectJson?.buildCacheOptions + ) { + throw new Error( + `The ${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath} file appears to be ` + + 'in an outdated format. Please see the UPGRADING.md notes for details. ' + + 'Quick link: https://rushjs.io/link/upgrading' + ); + } else { + throw e1; } } +} - private static _getRushProjectConfiguration( - project: RushConfigurationProject, - rushProjectJson: IRushProjectJson, - terminal: ITerminal - ): RushProjectConfiguration { - const operationSettingsByOperationName: Map = new Map< - string, - IOperationSettings - >(); - - let hasErrors: boolean = false; - - if (rushProjectJson.operationSettings) { - for (const operationSettings of rushProjectJson.operationSettings) { - const operationName: string = operationSettings.operationName; - const existingOperationSettings: IOperationSettings | undefined = - operationSettingsByOperationName.get(operationName); - if (existingOperationSettings) { - const existingOperationSettingsJsonPath: string | undefined = - RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(existingOperationSettings); - const operationSettingsJsonPath: string | undefined = - RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(operationSettings); - hasErrors = true; - let errorMessage: string = - `The operation "${operationName}" appears multiple times in the "${project.packageName}" project's ` + - `${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath} file's ` + - 'operationSettings property.'; - if (existingOperationSettingsJsonPath && operationSettingsJsonPath) { - if (existingOperationSettingsJsonPath !== operationSettingsJsonPath) { - errorMessage += - ` It first appears in "${existingOperationSettingsJsonPath}" and again ` + - `in "${operationSettingsJsonPath}".`; - } else if ( - !Path.convertToSlashes(existingOperationSettingsJsonPath).startsWith( - Path.convertToSlashes(project.projectFolder) - ) - ) { - errorMessage += ` It appears multiple times in "${operationSettingsJsonPath}".`; - } +/** + * Parses and validates the operation settings from the rush-project.json data. Returns the + * validated `operationSettingsByOperationName` map used to construct a {@link RushProjectConfiguration}. + * (The construction itself must remain in the class body because the constructor is private.) + */ +function _getRushProjectConfiguration( + project: RushConfigurationProject, + rushProjectJson: IRushProjectJson, + terminal: ITerminal +): ReadonlyMap { + const operationSettingsByOperationName: Map = new Map< + string, + IOperationSettings + >(); + + let hasErrors: boolean = false; + + if (rushProjectJson.operationSettings) { + for (const operationSettings of rushProjectJson.operationSettings) { + const operationName: string = operationSettings.operationName; + const existingOperationSettings: IOperationSettings | undefined = + operationSettingsByOperationName.get(operationName); + if (existingOperationSettings) { + const existingOperationSettingsJsonPath: string | undefined = + RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(existingOperationSettings); + const operationSettingsJsonPath: string | undefined = + RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(operationSettings); + hasErrors = true; + let errorMessage: string = + `The operation "${operationName}" appears multiple times in the "${project.packageName}" project's ` + + `${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath} file's ` + + 'operationSettings property.'; + if (existingOperationSettingsJsonPath && operationSettingsJsonPath) { + if (existingOperationSettingsJsonPath !== operationSettingsJsonPath) { + errorMessage += + ` It first appears in "${existingOperationSettingsJsonPath}" and again ` + + `in "${operationSettingsJsonPath}".`; + } else if ( + !Path.convertToSlashes(existingOperationSettingsJsonPath).startsWith( + Path.convertToSlashes(project.projectFolder) + ) + ) { + errorMessage += ` It appears multiple times in "${operationSettingsJsonPath}".`; } - - terminal.writeErrorLine(errorMessage); - } else { - operationSettingsByOperationName.set(operationName, operationSettings); } - } - for (const [operationName, operationSettings] of operationSettingsByOperationName) { - if (operationSettings.sharding?.shardOperationSettings) { - terminal.writeWarningLine( - `DEPRECATED: The "sharding.shardOperationSettings" field is deprecated. Please create a new operation, '${operationName}:shard' to track shard operation settings.` - ); - } + terminal.writeErrorLine(errorMessage); + } else { + operationSettingsByOperationName.set(operationName, operationSettings); } } - if (hasErrors) { - throw new AlreadyReportedError(); + for (const [operationName, operationSettings] of operationSettingsByOperationName) { + if (operationSettings.sharding?.shardOperationSettings) { + terminal.writeWarningLine( + `DEPRECATED: The "sharding.shardOperationSettings" field is deprecated. Please create a new operation, '${operationName}:shard' to track shard operation settings.` + ); + } } + } - return new RushProjectConfiguration(project, rushProjectJson, operationSettingsByOperationName); + if (hasErrors) { + throw new AlreadyReportedError(); } + + return operationSettingsByOperationName; } diff --git a/libraries/rush-lib/src/api/RushUserConfiguration.ts b/libraries/rush-lib/src/api/RushUserConfiguration.ts index cdf767363bb..7ff410d9d5f 100644 --- a/libraries/rush-lib/src/api/RushUserConfiguration.ts +++ b/libraries/rush-lib/src/api/RushUserConfiguration.ts @@ -12,14 +12,14 @@ interface IRushUserSettingsJson { buildCacheFolder?: string; } +const _schema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * Rush per-user configuration data. * * @beta */ export class RushUserConfiguration { - private static _schema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - /** * If provided, store build cache in the specified folder. Must be an absolute path. */ @@ -37,10 +37,7 @@ export class RushUserConfiguration { const rushUserSettingsFilePath: string = path.join(rushUserFolderPath, 'settings.json'); let rushUserSettingsJson: IRushUserSettingsJson | undefined; try { - rushUserSettingsJson = await JsonFile.loadAndValidateAsync( - rushUserSettingsFilePath, - RushUserConfiguration._schema - ); + rushUserSettingsJson = await JsonFile.loadAndValidateAsync(rushUserSettingsFilePath, _schema); } catch (e) { if (!FileSystem.isNotExistError(e as Error)) { throw e; diff --git a/libraries/rush-lib/src/api/SubspacesConfiguration.ts b/libraries/rush-lib/src/api/SubspacesConfiguration.ts index 91dbe477be3..e1c6a1cd21d 100644 --- a/libraries/rush-lib/src/api/SubspacesConfiguration.ts +++ b/libraries/rush-lib/src/api/SubspacesConfiguration.ts @@ -27,14 +27,14 @@ export interface ISubspacesConfigurationJson { subspaceNames: string[]; } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * This represents the subspace configurations for a repository, based on the "subspaces.json" * configuration file. * @beta */ export class SubspacesConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - /** * The absolute path to the "subspaces.json" configuration file that was loaded to construct this object. */ @@ -135,7 +135,7 @@ export class SubspacesConfiguration { ): SubspacesConfiguration | undefined { let configuration: Readonly | undefined; try { - configuration = JsonFile.loadAndValidate(subspaceJsonFilePath, SubspacesConfiguration._jsonSchema); + configuration = JsonFile.loadAndValidate(subspaceJsonFilePath, _jsonSchema); } catch (e) { if (!FileSystem.isNotExistError(e)) { throw e; diff --git a/libraries/rush-lib/src/api/VersionPolicyConfiguration.ts b/libraries/rush-lib/src/api/VersionPolicyConfiguration.ts index eff69dc2faa..16d1804561c 100644 --- a/libraries/rush-lib/src/api/VersionPolicyConfiguration.ts +++ b/libraries/rush-lib/src/api/VersionPolicyConfiguration.ts @@ -62,6 +62,8 @@ export interface IVersionPolicyDependencyJson { versionFormatForCommit?: VersionFormatForCommit; } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * Use this class to load and save the "common/config/rush/version-policies.json" config file. * This config file configures how different groups of projects will be published by Rush, @@ -69,8 +71,6 @@ export interface IVersionPolicyDependencyJson { * @public */ export class VersionPolicyConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - private _jsonFileName: string; /** @@ -170,10 +170,7 @@ export class VersionPolicyConfiguration { if (!FileSystem.exists(this._jsonFileName)) { return; } - const versionPolicyJson: IVersionPolicyJson[] = JsonFile.loadAndValidate( - this._jsonFileName, - VersionPolicyConfiguration._jsonSchema - ); + const versionPolicyJson: IVersionPolicyJson[] = JsonFile.loadAndValidate(this._jsonFileName, _jsonSchema); versionPolicyJson.forEach((policyJson) => { const policy: VersionPolicy | undefined = VersionPolicy.load(policyJson); diff --git a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts index ddcefbeafed..039dbb7df31 100644 --- a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts @@ -226,7 +226,7 @@ describe(RushConfiguration.name, () => { describe('PNPM Store Paths', () => { afterEach(() => { - EnvironmentConfiguration['_pnpmStorePathOverride'] = undefined; + EnvironmentConfiguration.reset(); }); const PNPM_STORE_PATH_ENV: string = 'RUSH_PNPM_STORE_PATH'; diff --git a/libraries/rush-lib/src/cli/CommandLineMigrationAdvisor.ts b/libraries/rush-lib/src/cli/CommandLineMigrationAdvisor.ts index dd96e13cf6a..15a40c596f7 100644 --- a/libraries/rush-lib/src/cli/CommandLineMigrationAdvisor.ts +++ b/libraries/rush-lib/src/cli/CommandLineMigrationAdvisor.ts @@ -14,35 +14,25 @@ export class CommandLineMigrationAdvisor { if (args.length > 0) { if (args[0] === 'generate') { - CommandLineMigrationAdvisor._reportDeprecated( - 'Instead of "rush generate", use "rush update" or "rush update --full".' - ); + _reportDeprecated('Instead of "rush generate", use "rush update" or "rush update --full".'); return false; } if (args[0] === 'install') { if (args.indexOf('--full-clean') >= 0) { - CommandLineMigrationAdvisor._reportDeprecated( - 'Instead of "rush install --full-clean", use "rush purge --unsafe".' - ); + _reportDeprecated('Instead of "rush install --full-clean", use "rush purge --unsafe".'); return false; } if (args.indexOf('-C') >= 0) { - CommandLineMigrationAdvisor._reportDeprecated( - 'Instead of "rush install -C", use "rush purge --unsafe".' - ); + _reportDeprecated('Instead of "rush install -C", use "rush purge --unsafe".'); return false; } if (args.indexOf('--clean') >= 0) { - CommandLineMigrationAdvisor._reportDeprecated( - 'Instead of "rush install --clean", use "rush install --purge".' - ); + _reportDeprecated('Instead of "rush install --clean", use "rush install --purge".'); return false; } if (args.indexOf('-c') >= 0) { - CommandLineMigrationAdvisor._reportDeprecated( - 'Instead of "rush install -c", use "rush install --purge".' - ); + _reportDeprecated('Instead of "rush install -c", use "rush install --purge".'); return false; } } @@ -51,26 +41,26 @@ export class CommandLineMigrationAdvisor { // Everything is okay return true; } +} - private static _reportDeprecated(message: string): void { - // eslint-disable-next-line no-console - console.error( - Colorize.red( - PrintUtilities.wrapWords( - 'ERROR: You specified an outdated command-line that is no longer supported by this version of Rush:' - ) - ) - ); - // eslint-disable-next-line no-console - console.error(Colorize.yellow(PrintUtilities.wrapWords(message))); - // eslint-disable-next-line no-console - console.error(); - // eslint-disable-next-line no-console - console.error( +function _reportDeprecated(message: string): void { + // eslint-disable-next-line no-console + console.error( + Colorize.red( PrintUtilities.wrapWords( - `For command-line help, type "rush -h". For migration instructions,` + - ` please visit ${RushConstants.rushWebSiteUrl}` + 'ERROR: You specified an outdated command-line that is no longer supported by this version of Rush:' ) - ); - } + ) + ); + // eslint-disable-next-line no-console + console.error(Colorize.yellow(PrintUtilities.wrapWords(message))); + // eslint-disable-next-line no-console + console.error(); + // eslint-disable-next-line no-console + console.error( + PrintUtilities.wrapWords( + `For command-line help, type "rush -h". For migration instructions,` + + ` please visit ${RushConstants.rushWebSiteUrl}` + ) + ); } diff --git a/libraries/rush-lib/src/cli/RushStartupBanner.ts b/libraries/rush-lib/src/cli/RushStartupBanner.ts index 132c3ba5b4a..989d96d3d5a 100644 --- a/libraries/rush-lib/src/cli/RushStartupBanner.ts +++ b/libraries/rush-lib/src/cli/RushStartupBanner.ts @@ -8,8 +8,8 @@ import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; export class RushStartupBanner { public static logBanner(rushVersion: string, isManaged: boolean): void { - const nodeVersion: string = this._formatNodeVersion(); - const versionSuffix: string = rushVersion ? ' ' + this._formatRushVersion(rushVersion, isManaged) : ''; + const nodeVersion: string = _formatNodeVersion(); + const versionSuffix: string = rushVersion ? ' ' + _formatRushVersion(rushVersion, isManaged) : ''; // eslint-disable-next-line no-console console.log( @@ -21,24 +21,24 @@ export class RushStartupBanner { } public static logStreamlinedBanner(rushVersion: string, isManaged: boolean): void { - const nodeVersion: string = this._formatNodeVersion(); - const versionSuffix: string = rushVersion ? ' ' + this._formatRushVersion(rushVersion, isManaged) : ''; + const nodeVersion: string = _formatNodeVersion(); + const versionSuffix: string = rushVersion ? ' ' + _formatRushVersion(rushVersion, isManaged) : ''; // eslint-disable-next-line no-console console.log(Colorize.bold(`Rush Multi-Project Build Tool${versionSuffix}`) + ` - Node.js ${nodeVersion}`); } +} - private static _formatNodeVersion(): string { - const nodeVersion: string = process.versions.node; - const nodeReleaseLabel: string = NodeJsCompatibility.isOddNumberedVersion - ? 'unstable' - : NodeJsCompatibility.isLtsVersion - ? 'LTS' - : 'pre-LTS'; - return `${nodeVersion} (${nodeReleaseLabel})`; - } +function _formatNodeVersion(): string { + const nodeVersion: string = process.versions.node; + const nodeReleaseLabel: string = NodeJsCompatibility.isOddNumberedVersion + ? 'unstable' + : NodeJsCompatibility.isLtsVersion + ? 'LTS' + : 'pre-LTS'; + return `${nodeVersion} (${nodeReleaseLabel})`; +} - private static _formatRushVersion(rushVersion: string, isManaged: boolean): string { - return rushVersion + Colorize.yellow(isManaged ? '' : ' (unmanaged)'); - } +function _formatRushVersion(rushVersion: string, isManaged: boolean): string { + return rushVersion + Colorize.yellow(isManaged ? '' : ' (unmanaged)'); } diff --git a/libraries/rush-lib/src/cli/RushXCommandLine.ts b/libraries/rush-lib/src/cli/RushXCommandLine.ts index 20a856b1f7b..20f550d65b8 100644 --- a/libraries/rush-lib/src/cli/RushXCommandLine.ts +++ b/libraries/rush-lib/src/cli/RushXCommandLine.ts @@ -80,7 +80,7 @@ class ProcessError extends Error { export class RushXCommandLine { public static async launchRushXAsync(launcherVersion: string, options: ILaunchOptions): Promise { try { - const rushxArguments: IRushXCommandLineArguments = RushXCommandLine._parseCommandLineArguments(); + const rushxArguments: IRushXCommandLineArguments = _parseCommandLineArguments(); const rushJsonFilePath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ showVerbose: false }); @@ -115,7 +115,7 @@ export class RushXCommandLine { // promise exception), so we start with the assumption that the exit code is 1 // and set it to 0 only on success. process.exitCode = 1; - await RushXCommandLine._launchRushXInternalAsync(terminal, rushxArguments, rushConfiguration, options); + await _launchRushXInternalAsync(terminal, rushxArguments, rushConfiguration, options); if (attemptHooks) { try { eventHooksManager?.handle(Event.postRushx, isDebug, ignoreHooks); @@ -137,255 +137,252 @@ export class RushXCommandLine { console.error(Colorize.red('Error: ' + (error as Error).message)); } } +} - private static async _launchRushXInternalAsync( - terminal: ITerminal, - rushxArguments: IRushXCommandLineArguments, - rushConfiguration: RushConfiguration | undefined, - options: ILaunchOptions - ): Promise { - const { quiet, help, commandName, commandArgs } = rushxArguments; +async function _launchRushXInternalAsync( + terminal: ITerminal, + rushxArguments: IRushXCommandLineArguments, + rushConfiguration: RushConfiguration | undefined, + options: ILaunchOptions +): Promise { + const { quiet, help, commandName, commandArgs } = rushxArguments; - if (!quiet) { - RushStartupBanner.logStreamlinedBanner(Rush.version, options.isManaged); - } - // Are we in a Rush repo? - NodeJsCompatibility.warnAboutCompatibilityIssues({ - isRushLib: true, - alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, - rushConfiguration - }); - - // Find the governing package.json for this folder: - const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup(); - - const packageJsonFilePath: string | undefined = packageJsonLookup.tryGetPackageJsonFilePathFor( - process.cwd() + if (!quiet) { + RushStartupBanner.logStreamlinedBanner(Rush.version, options.isManaged); + } + // Are we in a Rush repo? + NodeJsCompatibility.warnAboutCompatibilityIssues({ + isRushLib: true, + alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, + rushConfiguration + }); + + // Find the governing package.json for this folder: + const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup(); + + const packageJsonFilePath: string | undefined = packageJsonLookup.tryGetPackageJsonFilePathFor( + process.cwd() + ); + if (!packageJsonFilePath) { + throw Error( + 'This command should be used inside a project folder. ' + + 'Unable to find a package.json file in the current working directory or any of its parents.' ); - if (!packageJsonFilePath) { - throw Error( - 'This command should be used inside a project folder. ' + - 'Unable to find a package.json file in the current working directory or any of its parents.' - ); - } - - if (rushConfiguration && !rushConfiguration.tryGetProjectForPath(process.cwd())) { - // GitHub #2713: Users reported confusion resulting from a situation where "rush install" - // did not install the project's dependencies, because the project was not registered. - // eslint-disable-next-line no-console - console.log( - Colorize.yellow( - 'Warning: You are invoking "rushx" inside a Rush repository, but this project is not registered in ' + - `${RushConstants.rushJsonFilename}.` - ) - ); - } + } - const packageJson: IPackageJson = packageJsonLookup.loadPackageJson(packageJsonFilePath); + if (rushConfiguration && !rushConfiguration.tryGetProjectForPath(process.cwd())) { + // GitHub #2713: Users reported confusion resulting from a situation where "rush install" + // did not install the project's dependencies, because the project was not registered. + // eslint-disable-next-line no-console + console.log( + Colorize.yellow( + 'Warning: You are invoking "rushx" inside a Rush repository, but this project is not registered in ' + + `${RushConstants.rushJsonFilename}.` + ) + ); + } - const projectCommandSet: ProjectCommandSet = new ProjectCommandSet(packageJson); + const packageJson: IPackageJson = packageJsonLookup.loadPackageJson(packageJsonFilePath); - if (help) { - RushXCommandLine._showUsage(packageJson, projectCommandSet); - return; - } + const projectCommandSet: ProjectCommandSet = new ProjectCommandSet(packageJson); - const scriptBody: string | undefined = projectCommandSet.tryGetScriptBody(commandName); + if (help) { + _showUsage(packageJson, projectCommandSet); + return; + } - if (scriptBody === undefined) { - let errorMessage: string = `The command "${commandName}" is not defined in the package.json file for this project.`; + const scriptBody: string | undefined = projectCommandSet.tryGetScriptBody(commandName); - if (projectCommandSet.commandNames.length > 0) { - errorMessage += - '\nAvailable commands for this project are: ' + - projectCommandSet.commandNames.map((x) => `"${x}"`).join(', '); - } + if (scriptBody === undefined) { + let errorMessage: string = `The command "${commandName}" is not defined in the package.json file for this project.`; - throw Error(errorMessage); + if (projectCommandSet.commandNames.length > 0) { + errorMessage += + '\nAvailable commands for this project are: ' + + projectCommandSet.commandNames.map((x) => `"${x}"`).join(', '); } - let commandWithArgs: string = scriptBody; - let commandWithArgsForDisplay: string = scriptBody; - if (commandArgs.length > 0) { - const escapedRemainingArgs: string[] = commandArgs.map((x) => escapeArgumentIfNeeded(x)); - commandWithArgs += ' ' + escapedRemainingArgs.join(' '); + throw Error(errorMessage); + } - // Display it nicely without the extra quotes - commandWithArgsForDisplay += ' ' + commandArgs.join(' '); - } + let commandWithArgs: string = scriptBody; + let commandWithArgsForDisplay: string = scriptBody; + if (commandArgs.length > 0) { + const escapedRemainingArgs: string[] = commandArgs.map((x) => escapeArgumentIfNeeded(x)); + commandWithArgs += ' ' + escapedRemainingArgs.join(' '); - if (!quiet) { - // eslint-disable-next-line no-console - console.log(`> ${JSON.stringify(commandWithArgsForDisplay)}\n`); + // Display it nicely without the extra quotes + commandWithArgsForDisplay += ' ' + commandArgs.join(' '); + } + + if (!quiet) { + // eslint-disable-next-line no-console + console.log(`> ${JSON.stringify(commandWithArgsForDisplay)}\n`); + } + + const packageFolder: string = path.dirname(packageJsonFilePath); + + const exitCode: number = Utilities.executeLifecycleCommand(commandWithArgs, { + rushConfiguration, + workingDirectory: packageFolder, + // If there is a rush.json then use its .npmrc from the temp folder. + // Otherwise look for npmrc in the project folder. + initCwd: rushConfiguration ? rushConfiguration.commonTempFolder : packageFolder, + handleOutput: false, + environmentPathOptions: { + includeProjectBin: true } + }); - const packageFolder: string = path.dirname(packageJsonFilePath); - - const exitCode: number = Utilities.executeLifecycleCommand(commandWithArgs, { - rushConfiguration, - workingDirectory: packageFolder, - // If there is a rush.json then use its .npmrc from the temp folder. - // Otherwise look for npmrc in the project folder. - initCwd: rushConfiguration ? rushConfiguration.commonTempFolder : packageFolder, - handleOutput: false, - environmentPathOptions: { - includeProjectBin: true - } - }); - - if (rushConfiguration?.isPnpm && rushConfiguration?.experimentsConfiguration) { - const { configuration: experiments } = rushConfiguration?.experimentsConfiguration; - - if (experiments?.usePnpmSyncForInjectedDependencies) { - const pnpmSyncJsonPath: string = `${packageFolder}/${RushConstants.nodeModulesFolderName}/${RushConstants.pnpmSyncFilename}`; - if (await FileSystem.existsAsync(pnpmSyncJsonPath)) { - const { PackageExtractor } = await import( - /* webpackChunkName: 'PackageExtractor' */ - '@rushstack/package-extractor' - ); - await pnpmSyncCopyAsync({ - pnpmSyncJsonPath, - ensureFolderAsync: FileSystem.ensureFolderAsync, - forEachAsyncWithConcurrency: Async.forEachAsync, - getPackageIncludedFiles: PackageExtractor.getPackageIncludedFilesAsync, - logMessageCallback: (logMessageOptions: ILogMessageCallbackOptions) => - PnpmSyncUtilities.processLogMessage(logMessageOptions, terminal) - }); - } + if (rushConfiguration?.isPnpm && rushConfiguration?.experimentsConfiguration) { + const { configuration: experiments } = rushConfiguration?.experimentsConfiguration; + + if (experiments?.usePnpmSyncForInjectedDependencies) { + const pnpmSyncJsonPath: string = `${packageFolder}/${RushConstants.nodeModulesFolderName}/${RushConstants.pnpmSyncFilename}`; + if (await FileSystem.existsAsync(pnpmSyncJsonPath)) { + const { PackageExtractor } = await import( + /* webpackChunkName: 'PackageExtractor' */ + '@rushstack/package-extractor' + ); + await pnpmSyncCopyAsync({ + pnpmSyncJsonPath, + ensureFolderAsync: FileSystem.ensureFolderAsync, + forEachAsyncWithConcurrency: Async.forEachAsync, + getPackageIncludedFiles: PackageExtractor.getPackageIncludedFilesAsync, + logMessageCallback: (logMessageOptions: ILogMessageCallbackOptions) => + PnpmSyncUtilities.processLogMessage(logMessageOptions, terminal) + }); } } + } - if (exitCode > 0) { - throw new ProcessError(`Failed calling ${commandWithArgs}. Exit code: ${exitCode}`, exitCode); - } + if (exitCode > 0) { + throw new ProcessError(`Failed calling ${commandWithArgs}. Exit code: ${exitCode}`, exitCode); } +} - private static _parseCommandLineArguments(): IRushXCommandLineArguments { - // 0 = node.exe - // 1 = rushx - const args: string[] = process.argv.slice(2); - const unknownArgs: string[] = []; - - let help: boolean = false; - let quiet: boolean = false; - let commandName: string = ''; - let isDebug: boolean = false; - let ignoreHooks: boolean = false; - const commandArgs: string[] = []; - - for (let index: number = 0; index < args.length; index++) { - const argValue: string = args[index]; - - if (!commandName) { - if (argValue === '-q' || argValue === '--quiet') { - quiet = true; - } else if (argValue === '-h' || argValue === '--help') { - help = true; - } else if (argValue === '-d' || argValue === '--debug') { - isDebug = true; - } else if (argValue === '--ignore-hooks') { - ignoreHooks = true; - } else if (argValue.startsWith('-')) { - unknownArgs.push(args[index]); - } else { - commandName = args[index]; - } - } else { - commandArgs.push(args[index]); - } - } +function _parseCommandLineArguments(): IRushXCommandLineArguments { + // 0 = node.exe + // 1 = rushx + const args: string[] = process.argv.slice(2); + const unknownArgs: string[] = []; - const quietModeValue: string | undefined = - process.env[EnvironmentVariableNames.RUSH_QUIET_MODE]; - if (quietModeValue === '1' || quietModeValue === 'true') { - quiet = true; - } + let help: boolean = false; + let quiet: boolean = false; + let commandName: string = ''; + let isDebug: boolean = false; + let ignoreHooks: boolean = false; + const commandArgs: string[] = []; + + for (let index: number = 0; index < args.length; index++) { + const argValue: string = args[index]; if (!commandName) { - help = true; + if (argValue === '-q' || argValue === '--quiet') { + quiet = true; + } else if (argValue === '-h' || argValue === '--help') { + help = true; + } else if (argValue === '-d' || argValue === '--debug') { + isDebug = true; + } else if (argValue === '--ignore-hooks') { + ignoreHooks = true; + } else if (argValue.startsWith('-')) { + unknownArgs.push(args[index]); + } else { + commandName = args[index]; + } + } else { + commandArgs.push(args[index]); } + } - if (unknownArgs.length > 0) { - // Future TODO: Instead of just displaying usage info, we could display a - // specific error about the unknown flag the user tried to pass to rushx. - // eslint-disable-next-line no-console - console.log(Colorize.red(`Unknown arguments: ${unknownArgs.map((x) => JSON.stringify(x)).join(', ')}`)); - help = true; - } + const quietModeValue: string | undefined = process.env[EnvironmentVariableNames.RUSH_QUIET_MODE]; + if (quietModeValue === '1' || quietModeValue === 'true') { + quiet = true; + } - return { - help, - quiet, - isDebug, - ignoreHooks, - commandName, - commandArgs - }; + if (!commandName) { + help = true; } - private static _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommandSet): void { - // eslint-disable-next-line no-console - console.log('usage: rushx [-h]'); + if (unknownArgs.length > 0) { + // Future TODO: Instead of just displaying usage info, we could display a + // specific error about the unknown flag the user tried to pass to rushx. // eslint-disable-next-line no-console - console.log(' rushx [-q/--quiet] [-d/--debug] [--ignore-hooks] ...\n'); + console.log(Colorize.red(`Unknown arguments: ${unknownArgs.map((x) => JSON.stringify(x)).join(', ')}`)); + help = true; + } + return { + help, + quiet, + isDebug, + ignoreHooks, + commandName, + commandArgs + }; +} + +function _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommandSet): void { + // eslint-disable-next-line no-console + console.log('usage: rushx [-h]'); + // eslint-disable-next-line no-console + console.log(' rushx [-q/--quiet] [-d/--debug] [--ignore-hooks] ...\n'); + + // eslint-disable-next-line no-console + console.log('Optional arguments:'); + // eslint-disable-next-line no-console + console.log(' -h, --help Show this help message and exit.'); + // eslint-disable-next-line no-console + console.log(' -q, --quiet Hide rushx startup information.'); + // eslint-disable-next-line no-console + console.log(' -d, --debug Run in debug mode.\n'); + + if (projectCommandSet.commandNames.length > 0) { // eslint-disable-next-line no-console - console.log('Optional arguments:'); - // eslint-disable-next-line no-console - console.log(' -h, --help Show this help message and exit.'); - // eslint-disable-next-line no-console - console.log(' -q, --quiet Hide rushx startup information.'); - // eslint-disable-next-line no-console - console.log(' -d, --debug Run in debug mode.\n'); + console.log(`Project commands for ${Colorize.cyan(packageJson.name)}:`); - if (projectCommandSet.commandNames.length > 0) { - // eslint-disable-next-line no-console - console.log(`Project commands for ${Colorize.cyan(packageJson.name)}:`); + // Calculate the length of the longest script name, for formatting + let maxLength: number = 0; + for (const commandName of projectCommandSet.commandNames) { + maxLength = Math.max(maxLength, commandName.length); + } - // Calculate the length of the longest script name, for formatting - let maxLength: number = 0; - for (const commandName of projectCommandSet.commandNames) { - maxLength = Math.max(maxLength, commandName.length); - } + for (const commandName of projectCommandSet.commandNames) { + const escapedScriptBody: string = JSON.stringify(projectCommandSet.getScriptBody(commandName)); - for (const commandName of projectCommandSet.commandNames) { - const escapedScriptBody: string = JSON.stringify(projectCommandSet.getScriptBody(commandName)); - - // The length of the string e.g. " command: " - const firstPartLength: number = 2 + maxLength + 2; - // The length for truncating the escaped escapedScriptBody so it doesn't wrap - // to the next line - const consoleWidth: number = PrintUtilities.getConsoleWidth() || DEFAULT_CONSOLE_WIDTH; - const truncateLength: number = Math.max(0, consoleWidth - firstPartLength) - 1; - - // eslint-disable-next-line no-console - console.log( - // Example: " command: " - ' ' + - Colorize.cyan(Text.padEnd(commandName + ':', maxLength + 2)) + - // Example: "do some thin..." - Text.truncateWithEllipsis(escapedScriptBody, truncateLength) - ); - } + // The length of the string e.g. " command: " + const firstPartLength: number = 2 + maxLength + 2; + // The length for truncating the escaped escapedScriptBody so it doesn't wrap + // to the next line + const consoleWidth: number = PrintUtilities.getConsoleWidth() || DEFAULT_CONSOLE_WIDTH; + const truncateLength: number = Math.max(0, consoleWidth - firstPartLength) - 1; - if (projectCommandSet.malformedScriptNames.length > 0) { - // eslint-disable-next-line no-console - console.log( - '\n' + - Colorize.yellow( - 'Warning: Some "scripts" entries in the package.json file' + - ' have malformed names: ' + - projectCommandSet.malformedScriptNames.map((x) => `"${x}"`).join(', ') - ) - ); - } - } else { // eslint-disable-next-line no-console - console.log(Colorize.yellow('Warning: No commands are defined yet for this project.')); + console.log( + // Example: " command: " + ' ' + + Colorize.cyan(Text.padEnd(commandName + ':', maxLength + 2)) + + // Example: "do some thin..." + Text.truncateWithEllipsis(escapedScriptBody, truncateLength) + ); + } + + if (projectCommandSet.malformedScriptNames.length > 0) { // eslint-disable-next-line no-console console.log( - 'You can define a command by adding a "scripts" table to the project\'s package.json file.' + '\n' + + Colorize.yellow( + 'Warning: Some "scripts" entries in the package.json file' + + ' have malformed names: ' + + projectCommandSet.malformedScriptNames.map((x) => `"${x}"`).join(', ') + ) ); } + } else { + // eslint-disable-next-line no-console + console.log(Colorize.yellow('Warning: No commands are defined yet for this project.')); + // eslint-disable-next-line no-console + console.log('You can define a command by adding a "scripts" table to the project\'s package.json file.'); } } diff --git a/libraries/rush-lib/src/logic/ChangelogGenerator.ts b/libraries/rush-lib/src/logic/ChangelogGenerator.ts index a00fa0e1ff8..6ee830c6c8e 100644 --- a/libraries/rush-lib/src/logic/ChangelogGenerator.ts +++ b/libraries/rush-lib/src/logic/ChangelogGenerator.ts @@ -43,7 +43,7 @@ export class ChangelogGenerator { allChanges.packageChanges.forEach((change, packageName) => { const project: RushConfigurationProject | undefined = allProjects.get(packageName); - if (project && ChangelogGenerator._shouldUpdateChangeLog(project, allChanges)) { + if (project && _shouldUpdateChangeLog(project, allChanges)) { const changeLog: IChangelog | undefined = ChangelogGenerator.updateIndividualChangelog( change, project.projectFolder, @@ -79,15 +79,12 @@ export class ChangelogGenerator { throw new Error('A CHANGELOG.md without json: ' + markdownPath); } - const changelog: IChangelog = ChangelogGenerator._getChangelog( - project.packageName, - project.projectFolder - ); + const changelog: IChangelog = _getChangelog(project.packageName, project.projectFolder); const isLockstepped: boolean = !!project.versionPolicy && project.versionPolicy.isLockstepped; FileSystem.writeFile( path.join(project.projectFolder, CHANGELOG_MD), - ChangelogGenerator._translateToMarkdown(changelog, rushConfiguration, isLockstepped) + _translateToMarkdown(changelog, rushConfiguration, isLockstepped) ); } }); @@ -108,7 +105,7 @@ export class ChangelogGenerator { // Early return if the project is lockstepped and does not host change logs return undefined; } - const changelog: IChangelog = ChangelogGenerator._getChangelog(change.packageName, projectFolder); + const changelog: IChangelog = _getChangelog(change.packageName, projectFolder); if (!changelog.entries.some((entry) => entry.version === change.newVersion)) { const changelogEntry: IChangeLogEntry = { @@ -165,7 +162,7 @@ export class ChangelogGenerator { FileSystem.writeFile( path.join(projectFolder, CHANGELOG_MD), - ChangelogGenerator._translateToMarkdown(changelog, rushConfiguration, isLockstepped) + _translateToMarkdown(changelog, rushConfiguration, isLockstepped) ); } return changelog; @@ -173,121 +170,116 @@ export class ChangelogGenerator { // change log not updated. return undefined; } +} - /** - * Loads the changelog json from disk, or creates a new one if there isn't one. - */ - private static _getChangelog(packageName: string, projectFolder: string): IChangelog { - const changelogFilename: string = path.join(projectFolder, CHANGELOG_JSON); - let changelog: IChangelog | undefined = undefined; - - // Try to read the existing changelog. - if (FileSystem.exists(changelogFilename)) { - changelog = JsonFile.loadAndValidate(changelogFilename, ChangelogGenerator.jsonSchema); - } +/** + * Loads the changelog json from disk, or creates a new one if there isn't one. + */ +function _getChangelog(packageName: string, projectFolder: string): IChangelog { + const changelogFilename: string = path.join(projectFolder, CHANGELOG_JSON); + let changelog: IChangelog | undefined = undefined; - if (!changelog) { - changelog = { - name: packageName, - entries: [] - }; - } else { - // Force the changelog name to be same as package name. - // In case the package has been renamed but change log name is not updated. - changelog.name = packageName; - } + // Try to read the existing changelog. + if (FileSystem.exists(changelogFilename)) { + changelog = JsonFile.loadAndValidate(changelogFilename, ChangelogGenerator.jsonSchema); + } - return changelog; + if (!changelog) { + changelog = { + name: packageName, + entries: [] + }; + } else { + // Force the changelog name to be same as package name. + // In case the package has been renamed but change log name is not updated. + changelog.name = packageName; } - /** - * Translates the given changelog json object into a markdown string. - */ - private static _translateToMarkdown( - changelog: IChangelog, - rushConfiguration: RushConfiguration, - isLockstepped: boolean = false - ): string { - let markdown: string = [ - `# Change Log - ${changelog.name}`, - '', - `This log was last generated on ${new Date().toUTCString()} and should not be manually modified.`, - '', - '' - ].join(EOL); - - changelog.entries.forEach((entry, index) => { - markdown += `## ${entry.version}${EOL}`; - - if (entry.date) { - markdown += `${entry.date}${EOL}`; - } + return changelog; +} - markdown += EOL; +/** + * Translates the given changelog json object into a markdown string. + */ +function _translateToMarkdown( + changelog: IChangelog, + rushConfiguration: RushConfiguration, + isLockstepped: boolean = false +): string { + let markdown: string = [ + `# Change Log - ${changelog.name}`, + '', + `This log was last generated on ${new Date().toUTCString()} and should not be manually modified.`, + '', + '' + ].join(EOL); + + changelog.entries.forEach((entry, index) => { + markdown += `## ${entry.version}${EOL}`; + + if (entry.date) { + markdown += `${entry.date}${EOL}`; + } - let comments: string = ''; + markdown += EOL; - comments += ChangelogGenerator._getChangeComments('Breaking changes', entry.comments.major); + let comments: string = ''; - comments += ChangelogGenerator._getChangeComments('Minor changes', entry.comments.minor); + comments += _getChangeComments('Breaking changes', entry.comments.major); - comments += ChangelogGenerator._getChangeComments('Patches', entry.comments.patch); + comments += _getChangeComments('Minor changes', entry.comments.minor); - if (isLockstepped) { - // In lockstepped projects, all changes are of type ChangeType.none. - comments += ChangelogGenerator._getChangeComments('Updates', entry.comments.none); - } + comments += _getChangeComments('Patches', entry.comments.patch); - if (rushConfiguration.hotfixChangeEnabled) { - comments += ChangelogGenerator._getChangeComments('Hotfixes', entry.comments.hotfix); - } + if (isLockstepped) { + // In lockstepped projects, all changes are of type ChangeType.none. + comments += _getChangeComments('Updates', entry.comments.none); + } - if (!comments) { - markdown += - (changelog.entries.length === index + 1 ? '_Initial release_' : '_Version update only_') + - EOL + - EOL; - } else { - markdown += comments; - } - }); + if (rushConfiguration.hotfixChangeEnabled) { + comments += _getChangeComments('Hotfixes', entry.comments.hotfix); + } - return markdown; - } + if (!comments) { + markdown += + (changelog.entries.length === index + 1 ? '_Initial release_' : '_Version update only_') + EOL + EOL; + } else { + markdown += comments; + } + }); - /** - * Helper to return the comments string to be appends to the markdown content. - */ - private static _getChangeComments(title: string, commentsArray: IChangeLogComment[] | undefined): string { - let comments: string = ''; + return markdown; +} - if (commentsArray) { - comments = `### ${title}${EOL + EOL}`; - commentsArray.forEach((comment) => { - comments += `- ${comment.comment}${EOL}`; - }); - comments += EOL; - } +/** + * Helper to return the comments string to be appends to the markdown content. + */ +function _getChangeComments(title: string, commentsArray: IChangeLogComment[] | undefined): string { + let comments: string = ''; - return comments; + if (commentsArray) { + comments = `### ${title}${EOL + EOL}`; + commentsArray.forEach((comment) => { + comments += `- ${comment.comment}${EOL}`; + }); + comments += EOL; } - /** - * Changelogs should only be generated for publishable projects. - * Do not update changelog or delete the change files for prerelease. Save them for the official release. - * Unless the package is a hotfix, in which case do delete the change files. - * - * @param project - * @param allChanges - */ - private static _shouldUpdateChangeLog( - project: RushConfigurationProject, - allChanges: IChangeRequests - ): boolean { - return ( - project.shouldPublish && - (!semver.prerelease(project.packageJson.version) || - allChanges.packageChanges.get(project.packageName)?.changeType === ChangeType.hotfix) - ); - } + return comments; +} + +/** + * Changelogs should only be generated for publishable projects. + * Do not update changelog or delete the change files for prerelease. Save them for the official release. + * Unless the package is a hotfix, in which case do delete the change files. + * + * @param project + * @param allChanges + */ +function _shouldUpdateChangeLog(project: RushConfigurationProject, allChanges: IChangeRequests): boolean { + return ( + project.shouldPublish && + (!semver.prerelease(project.packageJson.version) || + allChanges.packageChanges.get(project.packageName)?.changeType === ChangeType.hotfix) + ); } diff --git a/libraries/rush-lib/src/logic/DependencyAnalyzer.ts b/libraries/rush-lib/src/logic/DependencyAnalyzer.ts index addb6054cc8..cca5a86385e 100644 --- a/libraries/rush-lib/src/logic/DependencyAnalyzer.ts +++ b/libraries/rush-lib/src/logic/DependencyAnalyzer.ts @@ -28,11 +28,9 @@ export interface IDependencyAnalysis { allVersionsByPackageName: Map>; } -export class DependencyAnalyzer { - private static _dependencyAnalyzerByRushConfiguration: - | WeakMap - | undefined; +let _dependencyAnalyzerByRushConfiguration: WeakMap | undefined; +export class DependencyAnalyzer { private _rushConfiguration: RushConfiguration; private _analysisByVariantBySubspace: Map> | undefined; @@ -41,15 +39,15 @@ export class DependencyAnalyzer { } public static forRushConfiguration(rushConfiguration: RushConfiguration): DependencyAnalyzer { - if (!DependencyAnalyzer._dependencyAnalyzerByRushConfiguration) { - DependencyAnalyzer._dependencyAnalyzerByRushConfiguration = new WeakMap(); + if (!_dependencyAnalyzerByRushConfiguration) { + _dependencyAnalyzerByRushConfiguration = new WeakMap(); } let analyzer: DependencyAnalyzer | undefined = - DependencyAnalyzer._dependencyAnalyzerByRushConfiguration.get(rushConfiguration); + _dependencyAnalyzerByRushConfiguration.get(rushConfiguration); if (!analyzer) { analyzer = new DependencyAnalyzer(rushConfiguration); - DependencyAnalyzer._dependencyAnalyzerByRushConfiguration.set(rushConfiguration, analyzer); + _dependencyAnalyzerByRushConfiguration.set(rushConfiguration, analyzer); } return analyzer; diff --git a/libraries/rush-lib/src/logic/NodeJsCompatibility.ts b/libraries/rush-lib/src/logic/NodeJsCompatibility.ts index e9cdbddc7cd..b39d4a954b1 100644 --- a/libraries/rush-lib/src/logic/NodeJsCompatibility.ts +++ b/libraries/rush-lib/src/logic/NodeJsCompatibility.ts @@ -76,8 +76,8 @@ export class NodeJsCompatibility { return ( NodeJsCompatibility.reportAncientIncompatibleVersion() || NodeJsCompatibility.warnAboutVersionTooNew(options) || - NodeJsCompatibility._warnAboutOddNumberedVersion() || - NodeJsCompatibility._warnAboutNonLtsVersion(options.rushConfiguration) + _warnAboutOddNumberedVersion() || + _warnAboutNonLtsVersion(options.rushConfiguration) ); } @@ -115,44 +115,44 @@ export class NodeJsCompatibility { } } - private static _warnAboutNonLtsVersion(rushConfiguration: RushConfiguration | undefined): boolean { - if (rushConfiguration && !rushConfiguration.suppressNodeLtsWarning && !NodeJsCompatibility.isLtsVersion) { - // eslint-disable-next-line no-console - console.warn( - Colorize.yellow( - `Your version of Node.js (${nodeVersion}) is not a Long-Term Support (LTS) release. ` + - 'These versions frequently have bugs. Please consider installing a stable release.\n' - ) - ); + public static get isLtsVersion(): boolean { + return !!process.release.lts; + } - return true; - } else { - return false; - } + public static get isOddNumberedVersion(): boolean { + return nodeMajorVersion % 2 !== 0; } +} - private static _warnAboutOddNumberedVersion(): boolean { - if (NodeJsCompatibility.isOddNumberedVersion) { - // eslint-disable-next-line no-console - console.warn( - Colorize.yellow( - `Your version of Node.js (${nodeVersion}) is an odd-numbered release. ` + - `These releases frequently have bugs. Please consider installing a Long Term Support (LTS) ` + - `version instead.\n` - ) - ); +function _warnAboutNonLtsVersion(rushConfiguration: RushConfiguration | undefined): boolean { + if (rushConfiguration && !rushConfiguration.suppressNodeLtsWarning && !NodeJsCompatibility.isLtsVersion) { + // eslint-disable-next-line no-console + console.warn( + Colorize.yellow( + `Your version of Node.js (${nodeVersion}) is not a Long-Term Support (LTS) release. ` + + 'These versions frequently have bugs. Please consider installing a stable release.\n' + ) + ); - return true; - } else { - return false; - } + return true; + } else { + return false; } +} - public static get isLtsVersion(): boolean { - return !!process.release.lts; - } +function _warnAboutOddNumberedVersion(): boolean { + if (NodeJsCompatibility.isOddNumberedVersion) { + // eslint-disable-next-line no-console + console.warn( + Colorize.yellow( + `Your version of Node.js (${nodeVersion}) is an odd-numbered release. ` + + `These releases frequently have bugs. Please consider installing a Long Term Support (LTS) ` + + `version instead.\n` + ) + ); - public static get isOddNumberedVersion(): boolean { - return nodeMajorVersion % 2 !== 0; + return true; + } else { + return false; } } diff --git a/libraries/rush-lib/src/logic/PublishUtilities.ts b/libraries/rush-lib/src/logic/PublishUtilities.ts index 7fa639ebded..1005e20922b 100644 --- a/libraries/rush-lib/src/logic/PublishUtilities.ts +++ b/libraries/rush-lib/src/logic/PublishUtilities.ts @@ -88,11 +88,11 @@ export class PublishUtilities { if (includeCommitDetails) { const git: Git = new Git(rushConfiguration); - await PublishUtilities._updateCommitDetailsAsync(git, changeFilePath, changes); + await _updateCommitDetailsAsync(git, changeFilePath, changes); } for (const change of changes) { - PublishUtilities._addChange({ + _addChange({ change, changeFilePath, allChanges, @@ -113,7 +113,7 @@ export class PublishUtilities { // For each requested package change, ensure downstream dependencies are also updated. allChanges.packageChanges.forEach((change, packageName) => { hasChanges = - PublishUtilities._updateDownstreamDependencies( + _updateDownstreamDependencies( change, allChanges, allPackages, @@ -134,7 +134,7 @@ export class PublishUtilities { return; } - const projectHasChanged: boolean = this._addChange({ + const projectHasChanged: boolean = _addChange({ change: { packageName: project.packageName, changeType: versionPolicyChange.changeType, @@ -165,18 +165,14 @@ export class PublishUtilities { const deps: Iterable = project.consumingProjects; // Write the new version expected for the change. - const skipVersionBump: boolean = PublishUtilities._shouldSkipVersionBump( - project, - prereleaseToken, - projectsToExclude - ); + const skipVersionBump: boolean = _shouldSkipVersionBump(project, prereleaseToken, projectsToExclude); if (skipVersionBump) { change.newVersion = packageJson.version; } else { // For hotfix changes, do not re-write new version change.newVersion = change.changeType! >= ChangeType.patch - ? semver.inc(packageJson.version, PublishUtilities._getReleaseType(change.changeType!))! + ? semver.inc(packageJson.version, _getReleaseType(change.changeType!))! : change.changeType === ChangeType.hotfix ? change.newVersion : packageJson.version; @@ -220,7 +216,7 @@ export class PublishUtilities { const updatedPackages: Map = new Map(); allChanges.packageChanges.forEach((change, packageName) => { - const updatedPackage: IPackageJson = PublishUtilities._writePackageChanges( + const updatedPackage: IPackageJson = _writePackageChanges( change, allChanges, allPackages, @@ -323,7 +319,7 @@ export class PublishUtilities { // These translate as `current`, `~current`, and `^current` when published newDependencyVersion = currentDependencyVersion; } else if (PublishUtilities.isRangeDependency(currentDependencyVersion)) { - newDependencyVersion = PublishUtilities._getNewRangeDependency(newProjectVersion); + newDependencyVersion = _getNewRangeDependency(newProjectVersion); } else if (currentDependencyVersion.lastIndexOf('~', 0) === 0) { newDependencyVersion = '~' + newProjectVersion; } else if (currentDependencyVersion.lastIndexOf('^', 0) === 0) { @@ -335,600 +331,593 @@ export class PublishUtilities { ? `workspace:${newDependencyVersion}` : newDependencyVersion; } +} - private static _getReleaseType(changeType: ChangeType): semver.ReleaseType { - switch (changeType) { - case ChangeType.major: - return 'major'; - case ChangeType.minor: - return 'minor'; - case ChangeType.patch: - return 'patch'; - case ChangeType.hotfix: - return 'prerelease'; - default: - throw new Error(`Wrong change type ${changeType}`); - } +function _getReleaseType(changeType: ChangeType): semver.ReleaseType { + switch (changeType) { + case ChangeType.major: + return 'major'; + case ChangeType.minor: + return 'minor'; + case ChangeType.patch: + return 'patch'; + case ChangeType.hotfix: + return 'prerelease'; + default: + throw new Error(`Wrong change type ${changeType}`); } +} - private static _getNewRangeDependency(newVersion: string): string { - let upperLimit: string = newVersion; - if (semver.prerelease(newVersion)) { - // Remove the prerelease first, then bump major. - upperLimit = semver.inc(newVersion, 'patch')!; - } - upperLimit = semver.inc(upperLimit, 'major')!; - - return `>=${newVersion} <${upperLimit}`; +function _getNewRangeDependency(newVersion: string): string { + let upperLimit: string = newVersion; + if (semver.prerelease(newVersion)) { + // Remove the prerelease first, then bump major. + upperLimit = semver.inc(newVersion, 'patch')!; } + upperLimit = semver.inc(upperLimit, 'major')!; - private static _shouldSkipVersionBump( - project: RushConfigurationProject, - prereleaseToken?: PrereleaseToken, - projectsToExclude?: Set - ): boolean { - // Suffix does not bump up the version. - // Excluded projects do not bump up version. - return ( - (prereleaseToken && prereleaseToken.isSuffix) || - (projectsToExclude && projectsToExclude.has(project.packageName)) || - !project.shouldPublish - ); - } + return `>=${newVersion} <${upperLimit}`; +} - private static async _updateCommitDetailsAsync( - git: Git, - filename: string, - changes: IChangeInfo[] - ): Promise { - try { - const gitPath: string = git.getGitPathOrThrow(); - const gitProcess: child_process.ChildProcess = Executable.spawn( - gitPath, - ['log', '-n', '1', '--', filename], - { - currentWorkingDirectory: path.dirname(filename) - } - ); - const { - stdout: fileLog, - exitCode, - signal - } = await Executable.waitForExitAsync(gitProcess, { - encoding: 'utf8' - }); - if (exitCode !== 0 || signal) { - return; +function _shouldSkipVersionBump( + project: RushConfigurationProject, + prereleaseToken?: PrereleaseToken, + projectsToExclude?: Set +): boolean { + // Suffix does not bump up the version. + // Excluded projects do not bump up version. + return ( + (prereleaseToken && prereleaseToken.isSuffix) || + (projectsToExclude && projectsToExclude.has(project.packageName)) || + !project.shouldPublish + ); +} + +/** + * Exported for unit tests only. + * @internal + */ +export async function _updateCommitDetailsAsync( + git: Git, + filename: string, + changes: IChangeInfo[] +): Promise { + try { + const gitPath: string = git.getGitPathOrThrow(); + const gitProcess: child_process.ChildProcess = Executable.spawn( + gitPath, + ['log', '-n', '1', '--', filename], + { + currentWorkingDirectory: path.dirname(filename) } + ); + const { + stdout: fileLog, + exitCode, + signal + } = await Executable.waitForExitAsync(gitProcess, { + encoding: 'utf8' + }); + if (exitCode !== 0 || signal) { + return; + } - const author: string = fileLog.match(/Author: (.*)/)![1]; - const commit: string = fileLog.match(/commit (.*)/)![1]; + const author: string = fileLog.match(/Author: (.*)/)![1]; + const commit: string = fileLog.match(/commit (.*)/)![1]; - changes.forEach((change) => { - change.author = author; - change.commit = commit; - }); - } catch (e) { - /* no-op, best effort. */ - } + changes.forEach((change) => { + change.author = author; + change.commit = commit; + }); + } catch (e) { + /* no-op, best effort. */ } +} - private static _writePackageChanges( - change: IChangeInfo, - allChanges: IChangeRequests, - allPackages: ReadonlyMap, - rushConfiguration: RushConfiguration, - shouldCommit: boolean, - prereleaseToken?: PrereleaseToken, - projectsToExclude?: Set - ): IPackageJson { - const project: RushConfigurationProject = allPackages.get(change.packageName)!; - const packageJson: IPackageJson = project.packageJson; +function _writePackageChanges( + change: IChangeInfo, + allChanges: IChangeRequests, + allPackages: ReadonlyMap, + rushConfiguration: RushConfiguration, + shouldCommit: boolean, + prereleaseToken?: PrereleaseToken, + projectsToExclude?: Set +): IPackageJson { + const project: RushConfigurationProject = allPackages.get(change.packageName)!; + const packageJson: IPackageJson = project.packageJson; + + const shouldSkipVersionBump: boolean = + !project.shouldPublish || (!!projectsToExclude && projectsToExclude.has(change.packageName)); + + const newVersion: string = shouldSkipVersionBump + ? packageJson.version + : _getChangeInfoNewVersion(change, prereleaseToken); + + if (!shouldSkipVersionBump) { + // eslint-disable-next-line no-console + console.log( + `\n* ${shouldCommit ? 'APPLYING' : 'DRYRUN'}: ${ChangeType[change.changeType!]} update ` + + `for ${change.packageName} to ${newVersion}` + ); + } else { + // eslint-disable-next-line no-console + console.log( + `\n* ${shouldCommit ? 'APPLYING' : 'DRYRUN'}: update for ${change.packageName} at ${newVersion}` + ); + } - const shouldSkipVersionBump: boolean = - !project.shouldPublish || (!!projectsToExclude && projectsToExclude.has(change.packageName)); + const packagePath: string = path.join(project.projectFolder, FileConstants.PackageJson); - const newVersion: string = shouldSkipVersionBump - ? packageJson.version - : PublishUtilities._getChangeInfoNewVersion(change, prereleaseToken); + packageJson.version = newVersion; - if (!shouldSkipVersionBump) { - // eslint-disable-next-line no-console - console.log( - `\n* ${shouldCommit ? 'APPLYING' : 'DRYRUN'}: ${ChangeType[change.changeType!]} update ` + - `for ${change.packageName} to ${newVersion}` - ); - } else { + // Update the package's dependencies. + _updateDependencies( + packageJson.name, + packageJson.dependencies, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + ); + // Update the package's dev dependencies. + _updateDependencies( + packageJson.name, + packageJson.devDependencies, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + ); + // Update the package's peer dependencies. + _updateDependencies( + packageJson.name, + packageJson.peerDependencies, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + ); + + change.changes!.forEach((subChange) => { + if (subChange.comment) { // eslint-disable-next-line no-console - console.log( - `\n* ${shouldCommit ? 'APPLYING' : 'DRYRUN'}: update for ${change.packageName} at ${newVersion}` - ); + console.log(` - [${ChangeType[subChange.changeType!]}] ${subChange.comment}`); } + }); - const packagePath: string = path.join(project.projectFolder, FileConstants.PackageJson); + if (shouldCommit) { + JsonFile.save(packageJson, packagePath, { updateExistingFile: true }); + } + return packageJson; +} - packageJson.version = newVersion; +function _isCyclicDependency( + allPackages: ReadonlyMap, + packageName: string, + dependencyName: string +): boolean { + const packageConfig: RushConfigurationProject | undefined = allPackages.get(packageName); + return !!packageConfig && packageConfig.decoupledLocalDependencies.has(dependencyName); +} - // Update the package's dependencies. - PublishUtilities._updateDependencies( - packageJson.name, - packageJson.dependencies, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - ); - // Update the package's dev dependencies. - PublishUtilities._updateDependencies( - packageJson.name, - packageJson.devDependencies, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - ); - // Update the package's peer dependencies. - PublishUtilities._updateDependencies( - packageJson.name, - packageJson.peerDependencies, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - ); +function _updateDependencies( + packageName: string, + dependencies: { [key: string]: string } | undefined, + allChanges: IChangeRequests, + allPackages: ReadonlyMap, + rushConfiguration: RushConfiguration, + prereleaseToken: PrereleaseToken | undefined, + projectsToExclude?: Set +): void { + if (dependencies) { + Object.keys(dependencies).forEach((depName) => { + if (!_isCyclicDependency(allPackages, packageName, depName)) { + const depChange: IChangeInfo | undefined = allChanges.packageChanges.get(depName); + if (!depChange) { + return; + } + const depProject: RushConfigurationProject = allPackages.get(depName)!; - change.changes!.forEach((subChange) => { - if (subChange.comment) { - // eslint-disable-next-line no-console - console.log(` - [${ChangeType[subChange.changeType!]}] ${subChange.comment}`); + if (!depProject.shouldPublish || (projectsToExclude && projectsToExclude.has(depName))) { + // No version change. + return; + } else if ( + prereleaseToken && + prereleaseToken.hasValue && + prereleaseToken.isPartialPrerelease && + depChange.changeType! < ChangeType.hotfix + ) { + // For partial prereleases, do not version bump dependencies with the `prereleaseToken` + // value unless an actual change (hotfix, patch, minor, major) has occurred + return; + } else if (depChange && prereleaseToken && prereleaseToken.hasValue) { + // TODO: treat prerelease version the same as non-prerelease version. + // For prerelease, the newVersion needs to be appended with prerelease name. + // And dependency should specify the specific prerelease version. + const currentSpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + depName, + dependencies[depName] + ); + const newVersion: string = _getChangeInfoNewVersion(depChange, prereleaseToken); + dependencies[depName] = + currentSpecifier.specifierType === DependencySpecifierType.Workspace + ? `workspace:${newVersion}` + : newVersion; + } else if (depChange && depChange.changeType! >= ChangeType.hotfix) { + _updateDependencyVersion( + packageName, + dependencies, + depName, + depChange, + allChanges, + allPackages, + rushConfiguration + ); + } } }); + } +} - if (shouldCommit) { - JsonFile.save(packageJson, packagePath, { updateExistingFile: true }); +/** + * Gets the new version from the ChangeInfo. + * The value of newVersion in ChangeInfo remains unchanged when the change type is dependency, + * However, for pre-release build, it won't pick up the updated pre-released dependencies. That is why + * this function should return a pre-released patch for that case. The exception to this is when we're + * running a partial pre-release build. In this case, only user-changed packages should update. + */ +function _getChangeInfoNewVersion(change: IChangeInfo, prereleaseToken: PrereleaseToken | undefined): string { + let newVersion: string = change.newVersion!; + if (prereleaseToken && prereleaseToken.hasValue) { + if (prereleaseToken.isPartialPrerelease && change.changeType! <= ChangeType.hotfix) { + return newVersion; } - return packageJson; + if (prereleaseToken.isPrerelease && change.changeType === ChangeType.dependency) { + newVersion = semver.inc(newVersion, 'patch')!; + } + return `${newVersion}-${prereleaseToken.name}`; + } else { + return newVersion; } +} - private static _isCyclicDependency( - allPackages: ReadonlyMap, - packageName: string, - dependencyName: string - ): boolean { - const packageConfig: RushConfigurationProject | undefined = allPackages.get(packageName); - return !!packageConfig && packageConfig.decoupledLocalDependencies.has(dependencyName); +/** + * Adds the given change to the packageChanges map. + * + * @returns true if the change caused the dependency change type to increase. + */ +function _addChange({ + change, + changeFilePath, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude +}: IAddChangeOptions): boolean { + let hasChanged: boolean = false; + const packageName: string = change.packageName; + const project: RushConfigurationProject | undefined = allPackages.get(packageName); + + if (!project) { + // eslint-disable-next-line no-console + console.log( + `The package ${packageName} was requested for publishing but does not exist. Skip this change.` + ); + return false; } - private static _updateDependencies( - packageName: string, - dependencies: { [key: string]: string } | undefined, - allChanges: IChangeRequests, - allPackages: ReadonlyMap, - rushConfiguration: RushConfiguration, - prereleaseToken: PrereleaseToken | undefined, - projectsToExclude?: Set - ): void { - if (dependencies) { - Object.keys(dependencies).forEach((depName) => { - if (!PublishUtilities._isCyclicDependency(allPackages, packageName, depName)) { - const depChange: IChangeInfo | undefined = allChanges.packageChanges.get(depName); - if (!depChange) { - return; - } - const depProject: RushConfigurationProject = allPackages.get(depName)!; - - if (!depProject.shouldPublish || (projectsToExclude && projectsToExclude.has(depName))) { - // No version change. - return; - } else if ( - prereleaseToken && - prereleaseToken.hasValue && - prereleaseToken.isPartialPrerelease && - depChange.changeType! < ChangeType.hotfix - ) { - // For partial prereleases, do not version bump dependencies with the `prereleaseToken` - // value unless an actual change (hotfix, patch, minor, major) has occurred - return; - } else if (depChange && prereleaseToken && prereleaseToken.hasValue) { - // TODO: treat prerelease version the same as non-prerelease version. - // For prerelease, the newVersion needs to be appended with prerelease name. - // And dependency should specify the specific prerelease version. - const currentSpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( - depName, - dependencies[depName] - ); - const newVersion: string = PublishUtilities._getChangeInfoNewVersion(depChange, prereleaseToken); - dependencies[depName] = - currentSpecifier.specifierType === DependencySpecifierType.Workspace - ? `workspace:${newVersion}` - : newVersion; - } else if (depChange && depChange.changeType! >= ChangeType.hotfix) { - PublishUtilities._updateDependencyVersion( - packageName, - dependencies, - depName, - depChange, - allChanges, - allPackages, - rushConfiguration - ); - } - } - }); - } - } + const packageJson: IPackageJson = project.packageJson; - /** - * Gets the new version from the ChangeInfo. - * The value of newVersion in ChangeInfo remains unchanged when the change type is dependency, - * However, for pre-release build, it won't pick up the updated pre-released dependencies. That is why - * this function should return a pre-released patch for that case. The exception to this is when we're - * running a partial pre-release build. In this case, only user-changed packages should update. - */ - private static _getChangeInfoNewVersion( - change: IChangeInfo, - prereleaseToken: PrereleaseToken | undefined - ): string { - let newVersion: string = change.newVersion!; - if (prereleaseToken && prereleaseToken.hasValue) { - if (prereleaseToken.isPartialPrerelease && change.changeType! <= ChangeType.hotfix) { - return newVersion; - } - if (prereleaseToken.isPrerelease && change.changeType === ChangeType.dependency) { - newVersion = semver.inc(newVersion, 'patch')!; + // If the given change does not have a changeType, derive it from the "type" string. + if (change.changeType === undefined) { + change.changeType = Enum.tryGetValueByKey(ChangeType, change.type!); + + if (change.changeType === undefined) { + if (changeFilePath) { + throw new Error(`Invalid change type ${JSON.stringify(change.type)} in ${changeFilePath}`); + } else { + throw new InternalError(`Invalid change type ${JSON.stringify(change.type)}`); } - return `${newVersion}-${prereleaseToken.name}`; - } else { - return newVersion; } } - /** - * Adds the given change to the packageChanges map. - * - * @returns true if the change caused the dependency change type to increase. - */ - private static _addChange({ - change, - changeFilePath, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - }: IAddChangeOptions): boolean { - let hasChanged: boolean = false; - const packageName: string = change.packageName; - const project: RushConfigurationProject | undefined = allPackages.get(packageName); + let currentChange: IChangeInfo | undefined = allChanges.packageChanges.get(packageName); - if (!project) { - // eslint-disable-next-line no-console - console.log( - `The package ${packageName} was requested for publishing but does not exist. Skip this change.` + if (currentChange === undefined) { + hasChanged = true; + currentChange = { + packageName, + changeType: change.changeType, + order: 0, + changes: [change] + }; + allChanges.packageChanges.set(packageName, currentChange); + } else { + const oldChangeType: ChangeType = currentChange.changeType!; + + if (oldChangeType === ChangeType.hotfix && change.changeType! > oldChangeType) { + throw new Error( + `Cannot apply ${_getReleaseType(change.changeType!)} change after hotfix on same package` ); - return false; } - - const packageJson: IPackageJson = project.packageJson; - - // If the given change does not have a changeType, derive it from the "type" string. - if (change.changeType === undefined) { - change.changeType = Enum.tryGetValueByKey(ChangeType, change.type!); - - if (change.changeType === undefined) { - if (changeFilePath) { - throw new Error(`Invalid change type ${JSON.stringify(change.type)} in ${changeFilePath}`); - } else { - throw new InternalError(`Invalid change type ${JSON.stringify(change.type)}`); - } - } + if (change.changeType! === ChangeType.hotfix && oldChangeType > change.changeType!) { + throw new Error( + `Cannot apply hotfix alongside ${_getReleaseType(oldChangeType!)} change on same package` + ); } - let currentChange: IChangeInfo | undefined = allChanges.packageChanges.get(packageName); - - if (currentChange === undefined) { - hasChanged = true; - currentChange = { - packageName, - changeType: change.changeType, - order: 0, - changes: [change] - }; - allChanges.packageChanges.set(packageName, currentChange); - } else { - const oldChangeType: ChangeType = currentChange.changeType!; - - if (oldChangeType === ChangeType.hotfix && change.changeType! > oldChangeType) { - throw new Error( - `Cannot apply ${this._getReleaseType(change.changeType!)} change after hotfix on same package` - ); - } - if (change.changeType! === ChangeType.hotfix && oldChangeType > change.changeType!) { - throw new Error( - `Cannot apply hotfix alongside ${this._getReleaseType(oldChangeType!)} change on same package` - ); - } + currentChange.changeType = Math.max(currentChange.changeType!, change.changeType!); + currentChange.changes!.push(change); - currentChange.changeType = Math.max(currentChange.changeType!, change.changeType!); - currentChange.changes!.push(change); + hasChanged = hasChanged || oldChangeType !== currentChange.changeType; + hasChanged = + hasChanged || + (change.newVersion !== undefined && + currentChange.newVersion !== undefined && + semver.gt(change.newVersion, currentChange.newVersion)); + } - hasChanged = hasChanged || oldChangeType !== currentChange.changeType; - hasChanged = - hasChanged || - (change.newVersion !== undefined && - currentChange.newVersion !== undefined && - semver.gt(change.newVersion, currentChange.newVersion)); - } + const skipVersionBump: boolean = _shouldSkipVersionBump(project, prereleaseToken, projectsToExclude); - const skipVersionBump: boolean = PublishUtilities._shouldSkipVersionBump( - project, - prereleaseToken, - projectsToExclude - ); + if (skipVersionBump) { + currentChange.newVersion = change.newVersion ?? packageJson.version; + hasChanged = false; + currentChange.changeType = ChangeType.none; + } else { + if (change.changeType === ChangeType.hotfix) { + const prereleaseComponents: ReadonlyArray | null = semver.prerelease( + packageJson.version + ); + if (!rushConfiguration.hotfixChangeEnabled) { + throw new Error(`Cannot add hotfix change; hotfixChangeEnabled is false in configuration.`); + } - if (skipVersionBump) { - currentChange.newVersion = change.newVersion ?? packageJson.version; - hasChanged = false; - currentChange.changeType = ChangeType.none; + currentChange.newVersion = change.newVersion ?? (packageJson.version as string); + if (!prereleaseComponents) { + currentChange.newVersion += '-hotfix'; + } + currentChange.newVersion = semver.inc(currentChange.newVersion, 'prerelease')!; } else { - if (change.changeType === ChangeType.hotfix) { - const prereleaseComponents: ReadonlyArray | null = semver.prerelease( - packageJson.version - ); - if (!rushConfiguration.hotfixChangeEnabled) { - throw new Error(`Cannot add hotfix change; hotfixChangeEnabled is false in configuration.`); - } - - currentChange.newVersion = change.newVersion ?? (packageJson.version as string); - if (!prereleaseComponents) { - currentChange.newVersion += '-hotfix'; - } - currentChange.newVersion = semver.inc(currentChange.newVersion, 'prerelease')!; - } else { - // When there are multiple changes of this package, the final value of new version - // should not depend on the order of the changes. - let packageVersion: string = change.newVersion ?? packageJson.version; - if (currentChange.newVersion && semver.gt(currentChange.newVersion, packageVersion)) { - packageVersion = currentChange.newVersion; - } + // When there are multiple changes of this package, the final value of new version + // should not depend on the order of the changes. + let packageVersion: string = change.newVersion ?? packageJson.version; + if (currentChange.newVersion && semver.gt(currentChange.newVersion, packageVersion)) { + packageVersion = currentChange.newVersion; + } - const shouldBump: boolean = - change.newVersion === undefined && change.changeType! >= ChangeType.hotfix; + const shouldBump: boolean = change.newVersion === undefined && change.changeType! >= ChangeType.hotfix; - currentChange.newVersion = shouldBump - ? semver.inc(packageVersion, PublishUtilities._getReleaseType(currentChange.changeType!))! - : packageVersion; + currentChange.newVersion = shouldBump + ? semver.inc(packageVersion, _getReleaseType(currentChange.changeType!))! + : packageVersion; - // set versionpolicy version to the current version + // set versionpolicy version to the current version + if ( + hasChanged && + project.versionPolicyName !== undefined && + project.versionPolicy !== undefined && + project.versionPolicy.isLockstepped + ) { + const projectVersionPolicy: LockStepVersionPolicy = project.versionPolicy as LockStepVersionPolicy; + const currentVersionPolicyChange: IVersionPolicyChangeInfo | undefined = + allChanges.versionPolicyChanges.get(project.versionPolicyName); if ( - hasChanged && - project.versionPolicyName !== undefined && - project.versionPolicy !== undefined && - project.versionPolicy.isLockstepped + projectVersionPolicy.nextBump === undefined && + (currentVersionPolicyChange === undefined || + semver.gt(currentChange.newVersion, currentVersionPolicyChange.newVersion)) ) { - const projectVersionPolicy: LockStepVersionPolicy = project.versionPolicy as LockStepVersionPolicy; - const currentVersionPolicyChange: IVersionPolicyChangeInfo | undefined = - allChanges.versionPolicyChanges.get(project.versionPolicyName); - if ( - projectVersionPolicy.nextBump === undefined && - (currentVersionPolicyChange === undefined || - semver.gt(currentChange.newVersion, currentVersionPolicyChange.newVersion)) - ) { - allChanges.versionPolicyChanges.set(project.versionPolicyName, { - versionPolicyName: project.versionPolicyName, - changeType: currentChange.changeType!, - newVersion: currentChange.newVersion - }); - } + allChanges.versionPolicyChanges.set(project.versionPolicyName, { + versionPolicyName: project.versionPolicyName, + changeType: currentChange.changeType!, + newVersion: currentChange.newVersion + }); } } - - // If hotfix change, force new range dependency to be the exact new version - currentChange.newRangeDependency = - change.changeType === ChangeType.hotfix - ? currentChange.newVersion - : PublishUtilities._getNewRangeDependency(currentChange.newVersion!); } - return hasChanged; + + // If hotfix change, force new range dependency to be the exact new version + currentChange.newRangeDependency = + change.changeType === ChangeType.hotfix + ? currentChange.newVersion + : _getNewRangeDependency(currentChange.newVersion!); } + return hasChanged; +} - private static _updateDownstreamDependencies( - change: IChangeInfo, - allChanges: IChangeRequests, - allPackages: ReadonlyMap, - rushConfiguration: RushConfiguration, - prereleaseToken: PrereleaseToken | undefined, - projectsToExclude?: Set - ): boolean { - let hasChanges: boolean = false; - const packageName: string = change.packageName; - const downstream: ReadonlySet = allPackages.get(packageName)!.consumingProjects; - - // Iterate through all downstream dependencies for the package. - if (downstream) { - if (change.changeType! >= ChangeType.hotfix || (prereleaseToken && prereleaseToken.hasValue)) { - for (const dependency of downstream) { - const packageJson: IPackageJson = dependency.packageJson; - - hasChanges = - PublishUtilities._updateDownstreamDependency( - packageJson.name, - packageJson.dependencies, - change, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - ) || hasChanges; - - hasChanges = - PublishUtilities._updateDownstreamDependency( - packageJson.name, - packageJson.devDependencies, - change, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - ) || hasChanges; - } +function _updateDownstreamDependencies( + change: IChangeInfo, + allChanges: IChangeRequests, + allPackages: ReadonlyMap, + rushConfiguration: RushConfiguration, + prereleaseToken: PrereleaseToken | undefined, + projectsToExclude?: Set +): boolean { + let hasChanges: boolean = false; + const packageName: string = change.packageName; + const downstream: ReadonlySet = allPackages.get(packageName)!.consumingProjects; + + // Iterate through all downstream dependencies for the package. + if (downstream) { + if (change.changeType! >= ChangeType.hotfix || (prereleaseToken && prereleaseToken.hasValue)) { + for (const dependency of downstream) { + const packageJson: IPackageJson = dependency.packageJson; + + hasChanges = + _updateDownstreamDependency( + packageJson.name, + packageJson.dependencies, + change, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + ) || hasChanges; + + hasChanges = + _updateDownstreamDependency( + packageJson.name, + packageJson.devDependencies, + change, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + ) || hasChanges; } } - - return hasChanges; } - private static _updateDownstreamDependency( - parentPackageName: string, - dependencies: { [packageName: string]: string } | undefined, - change: IChangeInfo, - allChanges: IChangeRequests, - allPackages: ReadonlyMap, - rushConfiguration: RushConfiguration, - prereleaseToken: PrereleaseToken | undefined, - projectsToExclude?: Set - ): boolean { - let hasChanges: boolean = false; - if ( - dependencies && - dependencies[change.packageName] && - !PublishUtilities._isCyclicDependency(allPackages, parentPackageName, change.packageName) - ) { - const requiredVersion: DependencySpecifier = DependencySpecifier.parseWithCache( - change.packageName, - dependencies[change.packageName] - ); - const isWorkspaceWildcardVersion: boolean = - requiredVersion.specifierType === DependencySpecifierType.Workspace && - MAGIC_SPECIFIERS.has(requiredVersion.versionSpecifier); - - const isPrerelease: boolean = - !!prereleaseToken && prereleaseToken.hasValue && !allChanges.packageChanges.has(parentPackageName); + return hasChanges; +} - // If the version range exists and has not yet been updated to this version, update it. - if ( - isPrerelease || - isWorkspaceWildcardVersion || - requiredVersion.versionSpecifier !== change.newRangeDependency - ) { - let changeType: ChangeType | undefined; - // Propagate hotfix changes to dependencies - if (change.changeType === ChangeType.hotfix) { - changeType = ChangeType.hotfix; - } else { - // Either it already satisfies the new version, or doesn't. - // If not, the downstream dep needs to be republished. - // The downstream dep will also need to be republished if using `workspace:*` as this will publish - // as the exact version. - changeType = - !isWorkspaceWildcardVersion && - semver.satisfies(change.newVersion!, requiredVersion.versionSpecifier) - ? ChangeType.dependency - : ChangeType.patch; - } +function _updateDownstreamDependency( + parentPackageName: string, + dependencies: { [packageName: string]: string } | undefined, + change: IChangeInfo, + allChanges: IChangeRequests, + allPackages: ReadonlyMap, + rushConfiguration: RushConfiguration, + prereleaseToken: PrereleaseToken | undefined, + projectsToExclude?: Set +): boolean { + let hasChanges: boolean = false; + if ( + dependencies && + dependencies[change.packageName] && + !_isCyclicDependency(allPackages, parentPackageName, change.packageName) + ) { + const requiredVersion: DependencySpecifier = DependencySpecifier.parseWithCache( + change.packageName, + dependencies[change.packageName] + ); + const isWorkspaceWildcardVersion: boolean = + requiredVersion.specifierType === DependencySpecifierType.Workspace && + MAGIC_SPECIFIERS.has(requiredVersion.versionSpecifier); - hasChanges = PublishUtilities._addChange({ - change: { - packageName: parentPackageName, - changeType - }, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - }); + const isPrerelease: boolean = + !!prereleaseToken && prereleaseToken.hasValue && !allChanges.packageChanges.has(parentPackageName); - if (hasChanges || isPrerelease) { - // Only re-evaluate downstream dependencies if updating the parent package's dependency - // caused a version bump. - hasChanges = - PublishUtilities._updateDownstreamDependencies( - allChanges.packageChanges.get(parentPackageName)!, - allChanges, - allPackages, - rushConfiguration, - prereleaseToken, - projectsToExclude - ) || hasChanges; - } + // If the version range exists and has not yet been updated to this version, update it. + if ( + isPrerelease || + isWorkspaceWildcardVersion || + requiredVersion.versionSpecifier !== change.newRangeDependency + ) { + let changeType: ChangeType | undefined; + // Propagate hotfix changes to dependencies + if (change.changeType === ChangeType.hotfix) { + changeType = ChangeType.hotfix; + } else { + // Either it already satisfies the new version, or doesn't. + // If not, the downstream dep needs to be republished. + // The downstream dep will also need to be republished if using `workspace:*` as this will publish + // as the exact version. + changeType = + !isWorkspaceWildcardVersion && + semver.satisfies(change.newVersion!, requiredVersion.versionSpecifier) + ? ChangeType.dependency + : ChangeType.patch; } - } - return hasChanges; - } + hasChanges = _addChange({ + change: { + packageName: parentPackageName, + changeType + }, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + }); - private static _getPublishDependencyVersion(specifier: DependencySpecifier, newVersion: string): string { - if (specifier.specifierType === DependencySpecifierType.Workspace) { - const { versionSpecifier } = specifier; - switch (versionSpecifier) { - case '*': - return newVersion; - case '~': - case '^': - return `${versionSpecifier}${newVersion}`; + if (hasChanges || isPrerelease) { + // Only re-evaluate downstream dependencies if updating the parent package's dependency + // caused a version bump. + hasChanges = + _updateDownstreamDependencies( + allChanges.packageChanges.get(parentPackageName)!, + allChanges, + allPackages, + rushConfiguration, + prereleaseToken, + projectsToExclude + ) || hasChanges; } } - return newVersion; } - private static _updateDependencyVersion( - packageName: string, - dependencies: { [key: string]: string }, - dependencyName: string, - dependencyChange: IChangeInfo, - allChanges: IChangeRequests, - allPackages: ReadonlyMap, - rushConfiguration: RushConfiguration - ): void { - let currentDependencyVersion: string | undefined = dependencies[dependencyName]; - let newDependencyVersion: string = PublishUtilities.getNewDependencyVersion( - dependencies, - dependencyName, - dependencyChange.newVersion! - ); - dependencies[dependencyName] = newDependencyVersion; - - // "*", "~", and "^" are special cases for workspace ranges, since it will publish using the exact - // version of the local dependency, so we need to modify what we write for our change - // comment - const currentDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( - dependencyName, - currentDependencyVersion - ); - currentDependencyVersion = - currentDependencySpecifier.specifierType === DependencySpecifierType.Workspace && - MAGIC_SPECIFIERS.has(currentDependencySpecifier.versionSpecifier) - ? undefined - : currentDependencySpecifier.versionSpecifier; - - const newDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( - dependencyName, - newDependencyVersion - ); - newDependencyVersion = PublishUtilities._getPublishDependencyVersion( - newDependencySpecifier, - dependencyChange.newVersion! - ); + return hasChanges; +} - // Add dependency version update comment. - PublishUtilities._addChange({ - change: { - packageName: packageName, - changeType: ChangeType.dependency, - comment: - `Updating dependency "${dependencyName}" ` + - (currentDependencyVersion ? `from \`${currentDependencyVersion}\` ` : '') + - `to \`${newDependencyVersion}\`` - }, - allChanges, - allPackages, - rushConfiguration - }); +function _getPublishDependencyVersion(specifier: DependencySpecifier, newVersion: string): string { + if (specifier.specifierType === DependencySpecifierType.Workspace) { + const { versionSpecifier } = specifier; + switch (versionSpecifier) { + case '*': + return newVersion; + case '~': + case '^': + return `${versionSpecifier}${newVersion}`; + } } + return newVersion; +} + +function _updateDependencyVersion( + packageName: string, + dependencies: { [key: string]: string }, + dependencyName: string, + dependencyChange: IChangeInfo, + allChanges: IChangeRequests, + allPackages: ReadonlyMap, + rushConfiguration: RushConfiguration +): void { + let currentDependencyVersion: string | undefined = dependencies[dependencyName]; + let newDependencyVersion: string = PublishUtilities.getNewDependencyVersion( + dependencies, + dependencyName, + dependencyChange.newVersion! + ); + dependencies[dependencyName] = newDependencyVersion; + + // "*", "~", and "^" are special cases for workspace ranges, since it will publish using the exact + // version of the local dependency, so we need to modify what we write for our change + // comment + const currentDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + dependencyName, + currentDependencyVersion + ); + currentDependencyVersion = + currentDependencySpecifier.specifierType === DependencySpecifierType.Workspace && + MAGIC_SPECIFIERS.has(currentDependencySpecifier.versionSpecifier) + ? undefined + : currentDependencySpecifier.versionSpecifier; + + const newDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + dependencyName, + newDependencyVersion + ); + newDependencyVersion = _getPublishDependencyVersion(newDependencySpecifier, dependencyChange.newVersion!); + + // Add dependency version update comment. + _addChange({ + change: { + packageName: packageName, + changeType: ChangeType.dependency, + comment: + `Updating dependency "${dependencyName}" ` + + (currentDependencyVersion ? `from \`${currentDependencyVersion}\` ` : '') + + `to \`${newDependencyVersion}\`` + }, + allChanges, + allPackages, + rushConfiguration + }); } diff --git a/libraries/rush-lib/src/logic/RepoStateFile.ts b/libraries/rush-lib/src/logic/RepoStateFile.ts index 0b7d907c7fd..499903848fa 100644 --- a/libraries/rush-lib/src/logic/RepoStateFile.ts +++ b/libraries/rush-lib/src/logic/RepoStateFile.ts @@ -38,6 +38,8 @@ interface IRepoStateJson { pnpmCatalogsHash?: string; } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * This file is used to track the state of various Rush-related features. It is generated * and updated by Rush. @@ -45,8 +47,6 @@ interface IRepoStateJson { * @public */ export class RepoStateFile { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - private _pnpmShrinkwrapHash: string | undefined; private _preferredVersionsHash: string | undefined; private _packageJsonInjectedDependenciesHash: string | undefined; @@ -148,7 +148,7 @@ export class RepoStateFile { } if (repoStateJson) { - this._jsonSchema.validateObject(repoStateJson, jsonFilename); + _jsonSchema.validateObject(repoStateJson, jsonFilename); } } diff --git a/libraries/rush-lib/src/logic/SetupChecks.ts b/libraries/rush-lib/src/logic/SetupChecks.ts index 3b6b9f1de1d..b7e9f1ae31e 100644 --- a/libraries/rush-lib/src/logic/SetupChecks.ts +++ b/libraries/rush-lib/src/logic/SetupChecks.ts @@ -31,7 +31,7 @@ const MINIMUM_SUPPORTED_PNPM_VERSION: string = '5.0.0'; export class SetupChecks { public static validate(rushConfiguration: RushConfiguration): void { // NOTE: The Node.js version is also checked in rush/src/start.ts - const errorMessage: string | undefined = SetupChecks._validate(rushConfiguration); + const errorMessage: string | undefined = _validate(rushConfiguration); if (errorMessage) { // eslint-disable-next-line no-console @@ -39,127 +39,127 @@ export class SetupChecks { throw new AlreadyReportedError(); } } +} - private static _validate(rushConfiguration: RushConfiguration): string | undefined { - // Check for outdated tools - if (rushConfiguration.isPnpm) { - if (semver.lt(rushConfiguration.packageManagerToolVersion, MINIMUM_SUPPORTED_PNPM_VERSION)) { - return ( - `The ${RushConstants.rushJsonFilename} file requests PNPM version ` + - rushConfiguration.packageManagerToolVersion + - `, but PNPM ${MINIMUM_SUPPORTED_PNPM_VERSION} is the minimum supported by Rush.` - ); - } - } else if (rushConfiguration.packageManager === 'npm') { - if (semver.lt(rushConfiguration.packageManagerToolVersion, MINIMUM_SUPPORTED_NPM_VERSION)) { - return ( - `The ${RushConstants.rushJsonFilename} file requests NPM version ` + - rushConfiguration.packageManagerToolVersion + - `, but NPM ${MINIMUM_SUPPORTED_NPM_VERSION} is the minimum supported by Rush.` - ); - } +function _validate(rushConfiguration: RushConfiguration): string | undefined { + // Check for outdated tools + if (rushConfiguration.isPnpm) { + if (semver.lt(rushConfiguration.packageManagerToolVersion, MINIMUM_SUPPORTED_PNPM_VERSION)) { + return ( + `The ${RushConstants.rushJsonFilename} file requests PNPM version ` + + rushConfiguration.packageManagerToolVersion + + `, but PNPM ${MINIMUM_SUPPORTED_PNPM_VERSION} is the minimum supported by Rush.` + ); + } + } else if (rushConfiguration.packageManager === 'npm') { + if (semver.lt(rushConfiguration.packageManagerToolVersion, MINIMUM_SUPPORTED_NPM_VERSION)) { + return ( + `The ${RushConstants.rushJsonFilename} file requests NPM version ` + + rushConfiguration.packageManagerToolVersion + + `, but NPM ${MINIMUM_SUPPORTED_NPM_VERSION} is the minimum supported by Rush.` + ); } - - SetupChecks._checkForPhantomFolders(rushConfiguration); } - private static _checkForPhantomFolders(rushConfiguration: RushConfiguration): void { - const phantomFolders: string[] = []; - const seenFolders: Set = new Set(); - - // Check from the real parent of the common/temp folder - const commonTempParent: string = path.dirname(FileSystem.getRealPath(rushConfiguration.commonTempFolder)); - SetupChecks._collectPhantomFoldersUpwards(commonTempParent, phantomFolders, seenFolders); - - // Check from the real folder containing rush.json - const realRushJsonFolder: string = FileSystem.getRealPath(rushConfiguration.rushJsonFolder); - SetupChecks._collectPhantomFoldersUpwards(realRushJsonFolder, phantomFolders, seenFolders); - - if (phantomFolders.length > 0) { - if (phantomFolders.length === 1) { - // eslint-disable-next-line no-console - console.log( - Colorize.yellow( - PrintUtilities.wrapWords( - 'Warning: A phantom "node_modules" folder was found. This defeats Rush\'s protection against' + - ' NPM phantom dependencies and may cause confusing build errors. It is recommended to' + - ' delete this folder:' - ) + _checkForPhantomFolders(rushConfiguration); +} + +function _checkForPhantomFolders(rushConfiguration: RushConfiguration): void { + const phantomFolders: string[] = []; + const seenFolders: Set = new Set(); + + // Check from the real parent of the common/temp folder + const commonTempParent: string = path.dirname(FileSystem.getRealPath(rushConfiguration.commonTempFolder)); + _collectPhantomFoldersUpwards(commonTempParent, phantomFolders, seenFolders); + + // Check from the real folder containing rush.json + const realRushJsonFolder: string = FileSystem.getRealPath(rushConfiguration.rushJsonFolder); + _collectPhantomFoldersUpwards(realRushJsonFolder, phantomFolders, seenFolders); + + if (phantomFolders.length > 0) { + if (phantomFolders.length === 1) { + // eslint-disable-next-line no-console + console.log( + Colorize.yellow( + PrintUtilities.wrapWords( + 'Warning: A phantom "node_modules" folder was found. This defeats Rush\'s protection against' + + ' NPM phantom dependencies and may cause confusing build errors. It is recommended to' + + ' delete this folder:' ) - ); - } else { - // eslint-disable-next-line no-console - console.log( - Colorize.yellow( - PrintUtilities.wrapWords( - 'Warning: Phantom "node_modules" folders were found. This defeats Rush\'s protection against' + - ' NPM phantom dependencies and may cause confusing build errors. It is recommended to' + - ' delete these folders:' - ) + ) + ); + } else { + // eslint-disable-next-line no-console + console.log( + Colorize.yellow( + PrintUtilities.wrapWords( + 'Warning: Phantom "node_modules" folders were found. This defeats Rush\'s protection against' + + ' NPM phantom dependencies and may cause confusing build errors. It is recommended to' + + ' delete these folders:' ) - ); - } - for (const folder of phantomFolders) { - // eslint-disable-next-line no-console - console.log(Colorize.yellow(`"${folder}"`)); - } + ) + ); + } + for (const folder of phantomFolders) { // eslint-disable-next-line no-console - console.log(); // add a newline + console.log(Colorize.yellow(`"${folder}"`)); } + // eslint-disable-next-line no-console + console.log(); // add a newline } +} - /** - * Checks "folder" and each of its parents to see if it contains a node_modules folder. - * The bad folders will be added to phantomFolders. - * The seenFolders set is used to avoid duplicates. - */ - private static _collectPhantomFoldersUpwards( - folder: string, - phantomFolders: string[], - seenFolders: Set - ): void { - // Stop if we reached a folder that we already analyzed - while (!seenFolders.has(folder)) { - seenFolders.add(folder); - - // If there is a node_modules folder under this folder, add it to the list of bad folders - const nodeModulesFolder: string = path.join(folder, RushConstants.nodeModulesFolderName); - if (FileSystem.exists(nodeModulesFolder)) { - // Collect the names of files/folders in that node_modules folder - const filenames: string[] = FileSystem.readFolderItemNames(nodeModulesFolder).filter( - (x) => !x.startsWith('.') - ); - - let ignore: boolean = false; - - if (filenames.length === 0) { - // If the node_modules folder is completely empty, then it's not a concern - ignore = true; - } else if (filenames.length === 1 && filenames[0] === 'vso-task-lib') { - // Special case: The Azure DevOps build agent installs the "vso-task-lib" NPM package - // in a top-level path such as: - // - // /home/vsts/work/node_modules/vso-task-lib - // - // It is always the only package in that node_modules folder. The "vso-task-lib" package - // is now deprecated, so it is unlikely to be a real dependency of any modern project. - // To avoid false alarms, we ignore this specific case. - ignore = true; - } - - if (!ignore) { - phantomFolders.push(nodeModulesFolder); - } +/** + * Checks "folder" and each of its parents to see if it contains a node_modules folder. + * The bad folders will be added to phantomFolders. + * The seenFolders set is used to avoid duplicates. + */ +function _collectPhantomFoldersUpwards( + folder: string, + phantomFolders: string[], + seenFolders: Set +): void { + // Stop if we reached a folder that we already analyzed + while (!seenFolders.has(folder)) { + seenFolders.add(folder); + + // If there is a node_modules folder under this folder, add it to the list of bad folders + const nodeModulesFolder: string = path.join(folder, RushConstants.nodeModulesFolderName); + if (FileSystem.exists(nodeModulesFolder)) { + // Collect the names of files/folders in that node_modules folder + const filenames: string[] = FileSystem.readFolderItemNames(nodeModulesFolder).filter( + (x) => !x.startsWith('.') + ); + + let ignore: boolean = false; + + if (filenames.length === 0) { + // If the node_modules folder is completely empty, then it's not a concern + ignore = true; + } else if (filenames.length === 1 && filenames[0] === 'vso-task-lib') { + // Special case: The Azure DevOps build agent installs the "vso-task-lib" NPM package + // in a top-level path such as: + // + // /home/vsts/work/node_modules/vso-task-lib + // + // It is always the only package in that node_modules folder. The "vso-task-lib" package + // is now deprecated, so it is unlikely to be a real dependency of any modern project. + // To avoid false alarms, we ignore this specific case. + ignore = true; } - // Walk upwards - const parentFolder: string = path.dirname(folder); - if (!parentFolder || parentFolder === folder) { - // If path.dirname() returns its own input, then means we reached the root - break; + if (!ignore) { + phantomFolders.push(nodeModulesFolder); } + } - folder = parentFolder; + // Walk upwards + const parentFolder: string = path.dirname(folder); + if (!parentFolder || parentFolder === folder) { + // If path.dirname() returns its own input, then means we reached the root + break; } + + folder = parentFolder; } } diff --git a/libraries/rush-lib/src/logic/StandardScriptUpdater.ts b/libraries/rush-lib/src/logic/StandardScriptUpdater.ts index 5a4fda349c7..de0f90ad48c 100644 --- a/libraries/rush-lib/src/logic/StandardScriptUpdater.ts +++ b/libraries/rush-lib/src/logic/StandardScriptUpdater.ts @@ -110,11 +110,7 @@ export class StandardScriptUpdater { await Async.forEachAsync( getScripts(rushConfiguration), async (script: IScriptSpecifier) => { - const changed: boolean = await StandardScriptUpdater._updateScriptOrThrowAsync( - script, - rushConfiguration, - false - ); + const changed: boolean = await _updateScriptOrThrowAsync(script, rushConfiguration, false); anyChanges ||= changed; }, { concurrency: 10 } @@ -136,85 +132,78 @@ export class StandardScriptUpdater { await Async.forEachAsync( getScripts(rushConfiguration), async (script: IScriptSpecifier) => { - await StandardScriptUpdater._updateScriptOrThrowAsync(script, rushConfiguration, true); + await _updateScriptOrThrowAsync(script, rushConfiguration, true); }, { concurrency: 10 } ); } +} - /** - * Compares a single script in the common/script folder to see if it needs to be updated. - * If throwInsteadOfCopy=false, then an outdated or missing script will be recopied; - * otherwise, an exception is thrown. - */ - private static async _updateScriptOrThrowAsync( - script: IScriptSpecifier, - rushConfiguration: RushConfiguration, - throwInsteadOfCopy: boolean - ): Promise { - const targetFilePath: string = `${rushConfiguration.commonScriptsFolder}/${script.scriptName}`; - - // Are the files the same? - let filesAreSame: boolean = false; - - let targetContent: string | undefined; - try { - targetContent = await FileSystem.readFileAsync(targetFilePath); - } catch (e) { - if (!FileSystem.isNotExistError(e)) { - throw e; - } - } - const targetNormalized: string | undefined = targetContent - ? StandardScriptUpdater._normalize(targetContent) - : undefined; - - let sourceNormalized: string; - if (targetNormalized) { - sourceNormalized = await StandardScriptUpdater._getExpectedFileDataAsync(script); - if (sourceNormalized === targetNormalized) { - filesAreSame = true; - } +/** + * Compares a single script in the common/script folder to see if it needs to be updated. + * If throwInsteadOfCopy=false, then an outdated or missing script will be recopied; + * otherwise, an exception is thrown. + */ +async function _updateScriptOrThrowAsync( + script: IScriptSpecifier, + rushConfiguration: RushConfiguration, + throwInsteadOfCopy: boolean +): Promise { + const targetFilePath: string = `${rushConfiguration.commonScriptsFolder}/${script.scriptName}`; + + // Are the files the same? + let filesAreSame: boolean = false; + + let targetContent: string | undefined; + try { + targetContent = await FileSystem.readFileAsync(targetFilePath); + } catch (e) { + if (!FileSystem.isNotExistError(e)) { + throw e; } + } + const targetNormalized: string | undefined = targetContent ? _normalize(targetContent) : undefined; - if (!filesAreSame) { - if (throwInsteadOfCopy) { - throw new Error( - 'The standard files in the "common/scripts" folders need to be updated' + - ' for this Rush version. Please run "rush update" and commit the changes.' - ); - } else { - // eslint-disable-next-line no-console - console.log(`Script is out of date; updating "${targetFilePath}"`); - sourceNormalized ||= await StandardScriptUpdater._getExpectedFileDataAsync(script); - await FileSystem.writeFileAsync(targetFilePath, sourceNormalized); - } + let sourceNormalized: string; + if (targetNormalized) { + sourceNormalized = await _getExpectedFileDataAsync(script); + if (sourceNormalized === targetNormalized) { + filesAreSame = true; } - - return !filesAreSame; } - private static _normalize(content: string): string { - // Ignore newline differences from .gitattributes - return ( - content - .split('\n') - // Ignore trailing whitespace - .map((x) => x.trimRight()) - .join('\n') - ); + if (!filesAreSame) { + if (throwInsteadOfCopy) { + throw new Error( + 'The standard files in the "common/scripts" folders need to be updated' + + ' for this Rush version. Please run "rush update" and commit the changes.' + ); + } else { + // eslint-disable-next-line no-console + console.log(`Script is out of date; updating "${targetFilePath}"`); + sourceNormalized ||= await _getExpectedFileDataAsync(script); + await FileSystem.writeFileAsync(targetFilePath, sourceNormalized); + } } - private static async _getExpectedFileDataAsync({ - scriptName, - headerLines - }: IScriptSpecifier): Promise { - const sourceFilePath: string = `${scriptsFolderPath}/${scriptName}`; - let sourceContent: string = await FileSystem.readFileAsync(sourceFilePath); - sourceContent = [...HEADER_LINES_PREFIX, ...headerLines, ...HEADER_LINES_SUFFIX, sourceContent].join( - '\n' - ); - const sourceNormalized: string = StandardScriptUpdater._normalize(sourceContent); - return sourceNormalized; - } + return !filesAreSame; +} + +function _normalize(content: string): string { + // Ignore newline differences from .gitattributes + return ( + content + .split('\n') + // Ignore trailing whitespace + .map((x) => x.trimRight()) + .join('\n') + ); +} + +async function _getExpectedFileDataAsync({ scriptName, headerLines }: IScriptSpecifier): Promise { + const sourceFilePath: string = `${scriptsFolderPath}/${scriptName}`; + let sourceContent: string = await FileSystem.readFileAsync(sourceFilePath); + sourceContent = [...HEADER_LINES_PREFIX, ...headerLines, ...HEADER_LINES_SUFFIX, sourceContent].join('\n'); + const sourceNormalized: string = _normalize(sourceContent); + return sourceNormalized; } diff --git a/libraries/rush-lib/src/logic/base/BaseLinkManager.ts b/libraries/rush-lib/src/logic/base/BaseLinkManager.ts index f664e7062ea..cca31d18f18 100644 --- a/libraries/rush-lib/src/logic/base/BaseLinkManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseLinkManager.ts @@ -102,83 +102,7 @@ export abstract class BaseLinkManager { Utilities.createFolderWithRetry(localModuleFolder); for (const child of localPackage.children) { - await BaseLinkManager._createSymlinksForDependenciesAsync(child); - } - } - } - - /** - * This is a helper function used by createSymlinksForTopLevelProject(). - * It will recursively creates symlinked folders corresponding to each of the - * Package objects in the provided tree. - */ - private static async _createSymlinksForDependenciesAsync(localPackage: BasePackage): Promise { - const localModuleFolder: string = path.join(localPackage.folderPath, 'node_modules'); - - if (!localPackage.symlinkTargetFolderPath) { - throw new InternalError('localPackage.symlinkTargetFolderPath was not assigned'); - } - - // This is special case for when localPackage.name has the form '@scope/name', - // in which case we need to create the '@scope' folder first. - const parentFolderPath: string = path.dirname(localPackage.folderPath); - if (parentFolderPath && parentFolderPath !== localPackage.folderPath) { - if (!FileSystem.exists(parentFolderPath)) { - Utilities.createFolderWithRetry(parentFolderPath); - } - } - - if (localPackage.children.length === 0) { - // If there are no children, then we can symlink the entire folder - await BaseLinkManager._createSymlinkAsync({ - linkTargetPath: localPackage.symlinkTargetFolderPath, - newLinkPath: localPackage.folderPath, - symlinkKind: SymlinkKind.Directory - }); - } else { - // If there are children, then we need to symlink each item in the folder individually - Utilities.createFolderWithRetry(localPackage.folderPath); - - for (const filename of FileSystem.readFolderItemNames(localPackage.symlinkTargetFolderPath)) { - if (filename.toLowerCase() !== 'node_modules') { - // Create the symlink - let symlinkKind: SymlinkKind = SymlinkKind.File; - - const linkSource: string = path.join(localPackage.folderPath, filename); - let linkTarget: string = path.join(localPackage.symlinkTargetFolderPath, filename); - - const linkStats: FileSystemStats = FileSystem.getLinkStatistics(linkTarget); - - if (linkStats.isSymbolicLink()) { - const targetStats: FileSystemStats = FileSystem.getStatistics(FileSystem.getRealPath(linkTarget)); - if (targetStats.isDirectory()) { - // Neither a junction nor a directory-symlink can have a directory-symlink - // as its target; instead, we must obtain the real physical path. - // A junction can link to another junction. Unfortunately, the node 'fs' API - // lacks the ability to distinguish between a junction and a directory-symlink - // (even though it has the ability to create them both), so the safest policy - // is to always make a junction and always to the real physical path. - linkTarget = FileSystem.getRealPath(linkTarget); - symlinkKind = SymlinkKind.Directory; - } - } else if (linkStats.isDirectory()) { - symlinkKind = SymlinkKind.Directory; - } - - await BaseLinkManager._createSymlinkAsync({ - linkTargetPath: linkTarget, - newLinkPath: linkSource, - symlinkKind - }); - } - } - } - - if (localPackage.children.length > 0) { - Utilities.createFolderWithRetry(localModuleFolder); - - for (const child of localPackage.children) { - await BaseLinkManager._createSymlinksForDependenciesAsync(child); + await _createSymlinksForDependenciesAsync(child); } } } @@ -211,3 +135,79 @@ export abstract class BaseLinkManager { protected abstract _linkProjectsAsync(): Promise; } + +/** + * This is a helper function used by createSymlinksForTopLevelProject(). + * It will recursively creates symlinked folders corresponding to each of the + * Package objects in the provided tree. + */ +async function _createSymlinksForDependenciesAsync(localPackage: BasePackage): Promise { + const localModuleFolder: string = path.join(localPackage.folderPath, 'node_modules'); + + if (!localPackage.symlinkTargetFolderPath) { + throw new InternalError('localPackage.symlinkTargetFolderPath was not assigned'); + } + + // This is special case for when localPackage.name has the form '@scope/name', + // in which case we need to create the '@scope' folder first. + const parentFolderPath: string = path.dirname(localPackage.folderPath); + if (parentFolderPath && parentFolderPath !== localPackage.folderPath) { + if (!FileSystem.exists(parentFolderPath)) { + Utilities.createFolderWithRetry(parentFolderPath); + } + } + + if (localPackage.children.length === 0) { + // If there are no children, then we can symlink the entire folder + await BaseLinkManager._createSymlinkAsync({ + linkTargetPath: localPackage.symlinkTargetFolderPath, + newLinkPath: localPackage.folderPath, + symlinkKind: SymlinkKind.Directory + }); + } else { + // If there are children, then we need to symlink each item in the folder individually + Utilities.createFolderWithRetry(localPackage.folderPath); + + for (const filename of FileSystem.readFolderItemNames(localPackage.symlinkTargetFolderPath)) { + if (filename.toLowerCase() !== 'node_modules') { + // Create the symlink + let symlinkKind: SymlinkKind = SymlinkKind.File; + + const linkSource: string = path.join(localPackage.folderPath, filename); + let linkTarget: string = path.join(localPackage.symlinkTargetFolderPath, filename); + + const linkStats: FileSystemStats = FileSystem.getLinkStatistics(linkTarget); + + if (linkStats.isSymbolicLink()) { + const targetStats: FileSystemStats = FileSystem.getStatistics(FileSystem.getRealPath(linkTarget)); + if (targetStats.isDirectory()) { + // Neither a junction nor a directory-symlink can have a directory-symlink + // as its target; instead, we must obtain the real physical path. + // A junction can link to another junction. Unfortunately, the node 'fs' API + // lacks the ability to distinguish between a junction and a directory-symlink + // (even though it has the ability to create them both), so the safest policy + // is to always make a junction and always to the real physical path. + linkTarget = FileSystem.getRealPath(linkTarget); + symlinkKind = SymlinkKind.Directory; + } + } else if (linkStats.isDirectory()) { + symlinkKind = SymlinkKind.Directory; + } + + await BaseLinkManager._createSymlinkAsync({ + linkTargetPath: linkTarget, + newLinkPath: linkSource, + symlinkKind + }); + } + } + } + + if (localPackage.children.length > 0) { + Utilities.createFolderWithRetry(localModuleFolder); + + for (const child of localPackage.children) { + await _createSymlinksForDependenciesAsync(child); + } + } +} diff --git a/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts b/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts index ef31aa8951c..b978869647b 100644 --- a/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts +++ b/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts @@ -83,12 +83,42 @@ function _getDirectFileTransferLockResourceName(cacheId: string): string { return crypto.createHash('sha1').update(cacheId).digest('hex'); } +let _tarUtilityPromise: Promise | undefined; + +function _getTempLocalCacheEntryPath(finalLocalCacheEntryPath: string): string { + // Derive the temp file from the destination path to ensure they are on the same volume. + // In the case of a shared network drive containing the build cache, we also need to make + // sure the temp path won't be shared by two parallel rush builds. + const randomSuffix: string = crypto.randomBytes(8).toString('hex'); + return `${finalLocalCacheEntryPath}-${randomSuffix}.temp`; +} + +function _tryGetTarUtility(terminal: ITerminal): Promise { + if (!_tarUtilityPromise) { + _tarUtilityPromise = TarExecutable.tryInitializeAsync(terminal); + } + + return _tarUtilityPromise; +} + +function _getCacheId(options: IProjectBuildCacheOptions): string | undefined { + const { + buildCacheConfiguration, + project: { packageName }, + operationStateHash, + phaseName + } = options; + return buildCacheConfiguration.getCacheEntryId({ + projectName: packageName, + projectStateHash: operationStateHash, + phaseName + }); +} + /** * @internal */ export class OperationBuildCache { - private static _tarUtilityPromise: Promise | undefined; - private readonly _project: RushConfigurationProject; private readonly _localBuildCacheProvider: FileSystemBuildCacheProvider; private readonly _cloudBuildCacheProvider: ICloudBuildCacheProvider | undefined; @@ -123,28 +153,12 @@ export class OperationBuildCache { this._useDirectFileTransfersForBuildCache = useDirectFileTransfersForBuildCache; } - private static _getTempLocalCacheEntryPath(finalLocalCacheEntryPath: string): string { - // Derive the temp file from the destination path to ensure they are on the same volume. - // In the case of a shared network drive containing the build cache, we also need to make - // sure the temp path won't be shared by two parallel rush builds. - const randomSuffix: string = crypto.randomBytes(8).toString('hex'); - return `${finalLocalCacheEntryPath}-${randomSuffix}.temp`; - } - - private static _tryGetTarUtility(terminal: ITerminal): Promise { - if (!OperationBuildCache._tarUtilityPromise) { - OperationBuildCache._tarUtilityPromise = TarExecutable.tryInitializeAsync(terminal); - } - - return OperationBuildCache._tarUtilityPromise; - } - public get cacheId(): string | undefined { return this._cacheId; } public static getOperationBuildCache(options: IProjectBuildCacheOptions): OperationBuildCache { - const cacheId: string | undefined = OperationBuildCache._getCacheId(options); + const cacheId: string | undefined = _getCacheId(options); return new OperationBuildCache(cacheId, options); } @@ -173,7 +187,7 @@ export class OperationBuildCache { excludeAppleDoubleFiles, useDirectFileTransfersForBuildCache }; - const cacheId: string | undefined = OperationBuildCache._getCacheId(buildCacheOptions); + const cacheId: string | undefined = _getCacheId(buildCacheOptions); return new OperationBuildCache(cacheId, buildCacheOptions); } @@ -237,7 +251,7 @@ export class OperationBuildCache { localCacheEntryPath = targetPath; updateLocalCacheSuccess = true; } else { - const tempTargetPath: string = OperationBuildCache._getTempLocalCacheEntryPath(targetPath); + const tempTargetPath: string = _getTempLocalCacheEntryPath(targetPath); try { const downloadedToTempFile: boolean = await this._cloudBuildCacheProvider.tryDownloadCacheEntryToFileAsync( @@ -317,7 +331,7 @@ export class OperationBuildCache { ) ); - const tarUtility: TarExecutable | undefined = await OperationBuildCache._tryGetTarUtility(terminal); + const tarUtility: TarExecutable | undefined = await _tryGetTarUtility(terminal); let restoreSuccess: boolean = false; if (tarUtility && localCacheEntryPath) { const logFilePath: string = this._getTarLogFilePath(cacheId, 'untar'); @@ -367,11 +381,10 @@ export class OperationBuildCache { let localCacheEntryPath: string | undefined; - const tarUtility: TarExecutable | undefined = await OperationBuildCache._tryGetTarUtility(terminal); + const tarUtility: TarExecutable | undefined = await _tryGetTarUtility(terminal); if (tarUtility) { const finalLocalCacheEntryPath: string = this._localBuildCacheProvider.getCacheEntryPath(cacheId); - const tempLocalCacheEntryPath: string = - OperationBuildCache._getTempLocalCacheEntryPath(finalLocalCacheEntryPath); + const tempLocalCacheEntryPath: string = _getTempLocalCacheEntryPath(finalLocalCacheEntryPath); const logFilePath: string = this._getTarLogFilePath(cacheId, 'tar'); const tarExitCode: number = await tarUtility.tryCreateArchiveFromProjectPathsAsync({ @@ -555,18 +568,4 @@ export class OperationBuildCache { private _getTarLogFilePath(cacheId: string, mode: 'tar' | 'untar'): string { return path.join(this._project.projectRushTempFolder, `${cacheId}.${mode}.log`); } - - private static _getCacheId(options: IProjectBuildCacheOptions): string | undefined { - const { - buildCacheConfiguration, - project: { packageName }, - operationStateHash, - phaseName - } = options; - return buildCacheConfiguration.getCacheEntryId({ - projectName: packageName, - projectStateHash: operationStateHash, - phaseName - }); - } } diff --git a/libraries/rush-lib/src/logic/deploy/DeployScenarioConfiguration.ts b/libraries/rush-lib/src/logic/deploy/DeployScenarioConfiguration.ts index c4d6f0c1cfd..f198eaad8ba 100644 --- a/libraries/rush-lib/src/logic/deploy/DeployScenarioConfiguration.ts +++ b/libraries/rush-lib/src/logic/deploy/DeployScenarioConfiguration.ts @@ -39,14 +39,14 @@ export interface IDeployScenarioJson { dependencySettings?: IDeployScenarioDependencyJson[]; } -export class DeployScenarioConfiguration { - // Used by validateScenarioName() - // Matches lowercase words separated by dashes. - // Example: "deploy-the-thing123" - private static _scenarioNameRegExp: RegExp = /^[a-z0-9]+(-[a-z0-9]+)*$/; +// Used by validateScenarioName() +// Matches lowercase words separated by dashes. +// Example: "deploy-the-thing123" +const _scenarioNameRegExp: RegExp = /^[a-z0-9]+(-[a-z0-9]+)*$/; - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); +export class DeployScenarioConfiguration { public readonly json: IDeployScenarioJson; /** @@ -69,7 +69,7 @@ export class DeployScenarioConfiguration { if (!scenarioName) { throw new Error('The scenario name cannot be an empty string'); } - if (!this._scenarioNameRegExp.test(scenarioName)) { + if (!_scenarioNameRegExp.test(scenarioName)) { throw new Error( `"${scenarioName}" is not a valid scenario name. The name must be comprised of` + ' lowercase letters and numbers, separated by single hyphens. Example: "my-scenario"' @@ -109,10 +109,7 @@ export class DeployScenarioConfiguration { terminal.writeLine(Colorize.cyan(`Loading deployment scenario: ${scenarioFilePath}`)); - const deployScenarioJson: IDeployScenarioJson = JsonFile.loadAndValidate( - scenarioFilePath, - DeployScenarioConfiguration._jsonSchema - ); + const deployScenarioJson: IDeployScenarioJson = JsonFile.loadAndValidate(scenarioFilePath, _jsonSchema); // Apply the defaults if (!deployScenarioJson.linkCreation) { diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 1f8e30251d9..397c039ed2e 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -383,7 +383,7 @@ export class InstallHelpers { configurationEnvironment = rushConfiguration.yarnOptions?.environmentVariables; } - return InstallHelpers._mergeEnvironmentVariables(process.env, configurationEnvironment, options); + return _mergeEnvironmentVariables(process.env, configurationEnvironment, options); } /** @@ -494,63 +494,63 @@ export class InstallHelpers { lock.release(); } +} - // Helper for getPackageManagerEnvironment - private static _mergeEnvironmentVariables( - baseEnv: NodeJS.ProcessEnv, - environmentVariables?: IConfigurationEnvironment, - options: { - debug?: boolean; - } = {} - ): NodeJS.ProcessEnv { - const packageManagerEnv: NodeJS.ProcessEnv = baseEnv; - - if (environmentVariables) { - // eslint-disable-next-line guard-for-in - for (const envVar in environmentVariables) { - let setEnvironmentVariable: boolean = true; +// Helper for getPackageManagerEnvironment +function _mergeEnvironmentVariables( + baseEnv: NodeJS.ProcessEnv, + environmentVariables?: IConfigurationEnvironment, + options: { + debug?: boolean; + } = {} +): NodeJS.ProcessEnv { + const packageManagerEnv: NodeJS.ProcessEnv = baseEnv; + + if (environmentVariables) { + // eslint-disable-next-line guard-for-in + for (const envVar in environmentVariables) { + let setEnvironmentVariable: boolean = true; + // eslint-disable-next-line no-console + console.log(`\nProcessing definition for environment variable: ${envVar}`); + + if (baseEnv.hasOwnProperty(envVar)) { + setEnvironmentVariable = false; + // eslint-disable-next-line no-console + console.log(`Environment variable already defined:`); // eslint-disable-next-line no-console - console.log(`\nProcessing definition for environment variable: ${envVar}`); + console.log(` Name: ${envVar}`); + // eslint-disable-next-line no-console + console.log(` Existing value: ${baseEnv[envVar]}`); + // eslint-disable-next-line no-console + console.log( + ` Value set in ${RushConstants.rushJsonFilename}: ${environmentVariables[envVar].value}` + ); - if (baseEnv.hasOwnProperty(envVar)) { - setEnvironmentVariable = false; - // eslint-disable-next-line no-console - console.log(`Environment variable already defined:`); - // eslint-disable-next-line no-console - console.log(` Name: ${envVar}`); - // eslint-disable-next-line no-console - console.log(` Existing value: ${baseEnv[envVar]}`); + if (environmentVariables[envVar].override) { + setEnvironmentVariable = true; // eslint-disable-next-line no-console console.log( - ` Value set in ${RushConstants.rushJsonFilename}: ${environmentVariables[envVar].value}` + `Overriding the environment variable with the value set in ${RushConstants.rushJsonFilename}.` ); - - if (environmentVariables[envVar].override) { - setEnvironmentVariable = true; - // eslint-disable-next-line no-console - console.log( - `Overriding the environment variable with the value set in ${RushConstants.rushJsonFilename}.` - ); - } else { - // eslint-disable-next-line no-console - console.log(Colorize.yellow(`WARNING: Not overriding the value of the environment variable.`)); - } + } else { + // eslint-disable-next-line no-console + console.log(Colorize.yellow(`WARNING: Not overriding the value of the environment variable.`)); } + } - if (setEnvironmentVariable) { - if (options.debug) { - // eslint-disable-next-line no-console - console.log(`Setting environment variable for package manager.`); - // eslint-disable-next-line no-console - console.log(` Name: ${envVar}`); - // eslint-disable-next-line no-console - console.log(` Value: ${environmentVariables[envVar].value}`); - } - packageManagerEnv[envVar] = environmentVariables[envVar].value; + if (setEnvironmentVariable) { + if (options.debug) { + // eslint-disable-next-line no-console + console.log(`Setting environment variable for package manager.`); + // eslint-disable-next-line no-console + console.log(` Name: ${envVar}`); + // eslint-disable-next-line no-console + console.log(` Value: ${environmentVariables[envVar].value}`); } + packageManagerEnv[envVar] = environmentVariables[envVar].value; } } - - return packageManagerEnv; } + + return packageManagerEnv; } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts index de73e3a5cc9..0db2eb38807 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts @@ -42,11 +42,7 @@ export class PnpmfileConfiguration { // Set the context to swallow log output and store our settings const context: IPnpmfileContext = { log: (message: string) => {}, - pnpmfileShimSettings: await PnpmfileConfiguration._getPnpmfileShimSettingsAsync( - rushConfiguration, - subspace, - variant - ) + pnpmfileShimSettings: await _getPnpmfileShimSettingsAsync(rushConfiguration, subspace, variant) }; return new PnpmfileConfiguration(context); @@ -75,8 +71,11 @@ export class PnpmfileConfiguration { destinationPath: pnpmfilePath }); - const pnpmfileShimSettings: IPnpmfileShimSettings = - await PnpmfileConfiguration._getPnpmfileShimSettingsAsync(rushConfiguration, subspace, variant); + const pnpmfileShimSettings: IPnpmfileShimSettings = await _getPnpmfileShimSettingsAsync( + rushConfiguration, + subspace, + variant + ); // Write the settings file used by the shim await JsonFile.saveAsync(pnpmfileShimSettings, path.join(targetDir, 'pnpmfileSettings.json'), { @@ -84,55 +83,6 @@ export class PnpmfileConfiguration { }); } - private static async _getPnpmfileShimSettingsAsync( - rushConfiguration: RushConfiguration, - subspace: Subspace, - variant: string | undefined - ): Promise { - let allPreferredVersions: { [dependencyName: string]: string } = {}; - let allowedAlternativeVersions: { [dependencyName: string]: readonly string[] } = {}; - const workspaceVersions: Record = {}; - - // Only workspaces shims in the common versions using pnpmfile - if ((rushConfiguration.packageManagerOptions as PnpmOptionsConfiguration).useWorkspaces) { - const commonVersionsConfiguration: CommonVersionsConfiguration = subspace.getCommonVersions(variant); - const preferredVersions: Map = new Map(); - MapExtensions.mergeFromMap( - preferredVersions, - rushConfiguration.getImplicitlyPreferredVersions(subspace, variant) - ); - for (const [name, version] of commonVersionsConfiguration.getAllPreferredVersions()) { - // Use the most restrictive version range available - if (!preferredVersions.has(name) || semver.subset(version, preferredVersions.get(name)!)) { - preferredVersions.set(name, version); - } - } - allPreferredVersions = MapExtensions.toObject(preferredVersions); - allowedAlternativeVersions = MapExtensions.toObject( - commonVersionsConfiguration.allowedAlternativeVersions - ); - - for (const project of rushConfiguration.projects) { - workspaceVersions[project.packageName] = project.packageJson.version; - } - } - - const settings: IPnpmfileShimSettings = { - allPreferredVersions, - allowedAlternativeVersions, - workspaceVersions, - semverPath: Import.resolveModule({ modulePath: 'semver', baseFolderPath: __dirname }) - }; - - // Use the provided path if available. Otherwise, use the default path. - const userPnpmfilePath: string | undefined = subspace.getPnpmfilePath(variant); - if (userPnpmfilePath && FileSystem.exists(userPnpmfilePath)) { - settings.userPnpmfilePath = userPnpmfilePath; - } - - return settings; - } - /** * Transform a package.json file using the pnpmfile.js hook. * @returns the transformed object, or the original input if pnpmfile.js was not found. @@ -145,3 +95,52 @@ export class PnpmfileConfiguration { } } } + +async function _getPnpmfileShimSettingsAsync( + rushConfiguration: RushConfiguration, + subspace: Subspace, + variant: string | undefined +): Promise { + let allPreferredVersions: { [dependencyName: string]: string } = {}; + let allowedAlternativeVersions: { [dependencyName: string]: readonly string[] } = {}; + const workspaceVersions: Record = {}; + + // Only workspaces shims in the common versions using pnpmfile + if ((rushConfiguration.packageManagerOptions as PnpmOptionsConfiguration).useWorkspaces) { + const commonVersionsConfiguration: CommonVersionsConfiguration = subspace.getCommonVersions(variant); + const preferredVersions: Map = new Map(); + MapExtensions.mergeFromMap( + preferredVersions, + rushConfiguration.getImplicitlyPreferredVersions(subspace, variant) + ); + for (const [name, version] of commonVersionsConfiguration.getAllPreferredVersions()) { + // Use the most restrictive version range available + if (!preferredVersions.has(name) || semver.subset(version, preferredVersions.get(name)!)) { + preferredVersions.set(name, version); + } + } + allPreferredVersions = MapExtensions.toObject(preferredVersions); + allowedAlternativeVersions = MapExtensions.toObject( + commonVersionsConfiguration.allowedAlternativeVersions + ); + + for (const project of rushConfiguration.projects) { + workspaceVersions[project.packageName] = project.packageJson.version; + } + } + + const settings: IPnpmfileShimSettings = { + allPreferredVersions, + allowedAlternativeVersions, + workspaceVersions, + semverPath: Import.resolveModule({ modulePath: 'semver', baseFolderPath: __dirname }) + }; + + // Use the provided path if available. Otherwise, use the default path. + const userPnpmfilePath: string | undefined = subspace.getPnpmfilePath(variant); + if (userPnpmfilePath && FileSystem.exists(userPnpmfilePath)) { + settings.userPnpmfilePath = userPnpmfilePath; + } + + return settings; +} diff --git a/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts index 6493ea0fa66..b190671a0d0 100644 --- a/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts @@ -68,7 +68,7 @@ export class SubspacePnpmfileConfiguration { const projectNameToInjectedDependenciesMap: Map< string, Set - > = SubspacePnpmfileConfiguration._getProjectNameToInjectedDependenciesMap(rushConfiguration, subspace); + > = _getProjectNameToInjectedDependenciesMap(rushConfiguration, subspace); for (const project of rushConfiguration.projects) { const { packageName, projectRelativeFolder, packageJson } = project; const workspaceProjectInfo: IWorkspaceProjectInfo = { @@ -97,113 +97,112 @@ export class SubspacePnpmfileConfiguration { return settings; } +} - private static _getProjectNameToInjectedDependenciesMap( - rushConfiguration: RushConfiguration, - subspace: Subspace - ): Map> { - const projectNameToInjectedDependenciesMap: Map> = new Map(); - - const workspaceProjectsMap: Map = new Map(); - const subspaceProjectsMap: Map = new Map(); - for (const project of rushConfiguration.projects) { - if (subspace.contains(project)) { - subspaceProjectsMap.set(project.packageName, project); - } else { - workspaceProjectsMap.set(project.packageName, project); - } - - projectNameToInjectedDependenciesMap.set(project.packageName, new Set()); +function _getProjectNameToInjectedDependenciesMap( + rushConfiguration: RushConfiguration, + subspace: Subspace +): Map> { + const projectNameToInjectedDependenciesMap: Map> = new Map(); + + const workspaceProjectsMap: Map = new Map(); + const subspaceProjectsMap: Map = new Map(); + for (const project of rushConfiguration.projects) { + if (subspace.contains(project)) { + subspaceProjectsMap.set(project.packageName, project); + } else { + workspaceProjectsMap.set(project.packageName, project); } - const processTransitiveInjectedInstallQueue: Array = []; - - for (const subspaceProject of subspaceProjectsMap.values()) { - const injectedDependencySet: Set = new Set(); - const dependenciesMeta: IDependenciesMetaTable | undefined = - subspaceProject.packageJson.dependenciesMeta; - if (dependenciesMeta) { - for (const [dependencyName, { injected }] of Object.entries(dependenciesMeta)) { - if (injected) { - injectedDependencySet.add(dependencyName); - projectNameToInjectedDependenciesMap.get(subspaceProject.packageName)?.add(dependencyName); - - //if this dependency is in the same subspace, leave as it is, PNPM will handle it - //if this dependency is in another subspace, then it is transitive injected installation - //so, we need to let all the workspace dependencies along the dependency chain to use injected installation - if (!subspaceProjectsMap.has(dependencyName)) { - processTransitiveInjectedInstallQueue.push(workspaceProjectsMap.get(dependencyName)!); - } - } - } - } + projectNameToInjectedDependenciesMap.set(project.packageName, new Set()); + } - // if alwaysInjectDependenciesFromOtherSubspaces policy is true in pnpm-config.json - // and the dependency is not injected yet - // and the dependency is in another subspace - // then, make this dependency as injected dependency - const pnpmOptions: PnpmOptionsConfiguration | undefined = - subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; - if (pnpmOptions && pnpmOptions.alwaysInjectDependenciesFromOtherSubspaces) { - const dependencyProjects: ReadonlySet = subspaceProject.dependencyProjects; - for (const dependencyProject of dependencyProjects) { - const dependencyName: string = dependencyProject.packageName; - if (!injectedDependencySet.has(dependencyName) && !subspaceProjectsMap.has(dependencyName)) { - projectNameToInjectedDependenciesMap.get(subspaceProject.packageName)?.add(dependencyName); - // process transitive injected installation + const processTransitiveInjectedInstallQueue: Array = []; + + for (const subspaceProject of subspaceProjectsMap.values()) { + const injectedDependencySet: Set = new Set(); + const dependenciesMeta: IDependenciesMetaTable | undefined = subspaceProject.packageJson.dependenciesMeta; + if (dependenciesMeta) { + for (const [dependencyName, { injected }] of Object.entries(dependenciesMeta)) { + if (injected) { + injectedDependencySet.add(dependencyName); + projectNameToInjectedDependenciesMap.get(subspaceProject.packageName)?.add(dependencyName); + + //if this dependency is in the same subspace, leave as it is, PNPM will handle it + //if this dependency is in another subspace, then it is transitive injected installation + //so, we need to let all the workspace dependencies along the dependency chain to use injected installation + if (!subspaceProjectsMap.has(dependencyName)) { processTransitiveInjectedInstallQueue.push(workspaceProjectsMap.get(dependencyName)!); } } } } - // rewrite all workspace dependencies to injected install all for transitive injected installation case - while (processTransitiveInjectedInstallQueue.length > 0) { - const currentProject: RushConfigurationProject | undefined = - processTransitiveInjectedInstallQueue.shift(); - const dependencies: Record | undefined = currentProject?.packageJson?.dependencies; - const optionalDependencies: Record | undefined = - currentProject?.packageJson?.optionalDependencies; - if (currentProject) { - if (dependencies) { - SubspacePnpmfileConfiguration._processDependenciesForTransitiveInjectedInstall( - projectNameToInjectedDependenciesMap, - processTransitiveInjectedInstallQueue, - dependencies, - currentProject, - rushConfiguration - ); - } - if (optionalDependencies) { - SubspacePnpmfileConfiguration._processDependenciesForTransitiveInjectedInstall( - projectNameToInjectedDependenciesMap, - processTransitiveInjectedInstallQueue, - optionalDependencies, - currentProject, - rushConfiguration - ); + // if alwaysInjectDependenciesFromOtherSubspaces policy is true in pnpm-config.json + // and the dependency is not injected yet + // and the dependency is in another subspace + // then, make this dependency as injected dependency + const pnpmOptions: PnpmOptionsConfiguration | undefined = + subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; + if (pnpmOptions && pnpmOptions.alwaysInjectDependenciesFromOtherSubspaces) { + const dependencyProjects: ReadonlySet = subspaceProject.dependencyProjects; + for (const dependencyProject of dependencyProjects) { + const dependencyName: string = dependencyProject.packageName; + if (!injectedDependencySet.has(dependencyName) && !subspaceProjectsMap.has(dependencyName)) { + projectNameToInjectedDependenciesMap.get(subspaceProject.packageName)?.add(dependencyName); + // process transitive injected installation + processTransitiveInjectedInstallQueue.push(workspaceProjectsMap.get(dependencyName)!); } } } + } - return projectNameToInjectedDependenciesMap; + // rewrite all workspace dependencies to injected install all for transitive injected installation case + while (processTransitiveInjectedInstallQueue.length > 0) { + const currentProject: RushConfigurationProject | undefined = + processTransitiveInjectedInstallQueue.shift(); + const dependencies: Record | undefined = currentProject?.packageJson?.dependencies; + const optionalDependencies: Record | undefined = + currentProject?.packageJson?.optionalDependencies; + if (currentProject) { + if (dependencies) { + _processDependenciesForTransitiveInjectedInstall( + projectNameToInjectedDependenciesMap, + processTransitiveInjectedInstallQueue, + dependencies, + currentProject, + rushConfiguration + ); + } + if (optionalDependencies) { + _processDependenciesForTransitiveInjectedInstall( + projectNameToInjectedDependenciesMap, + processTransitiveInjectedInstallQueue, + optionalDependencies, + currentProject, + rushConfiguration + ); + } + } } - private static _processDependenciesForTransitiveInjectedInstall( - projectNameToInjectedDependencies: Map>, - processTransitiveInjectedInstallQueue: Array, - dependencies: Record, - currentProject: RushConfigurationProject, - rushConfiguration: RushConfiguration - ): void { - for (const dependencyName in dependencies) { - if (dependencies[dependencyName].startsWith('workspace:')) { - projectNameToInjectedDependencies.get(currentProject.packageName)?.add(dependencyName); - const nextProject: RushConfigurationProject | undefined = - rushConfiguration.getProjectByName(dependencyName); - if (nextProject) { - processTransitiveInjectedInstallQueue.push(nextProject); - } + return projectNameToInjectedDependenciesMap; +} + +function _processDependenciesForTransitiveInjectedInstall( + projectNameToInjectedDependencies: Map>, + processTransitiveInjectedInstallQueue: Array, + dependencies: Record, + currentProject: RushConfigurationProject, + rushConfiguration: RushConfiguration +): void { + for (const dependencyName in dependencies) { + if (dependencies[dependencyName].startsWith('workspace:')) { + projectNameToInjectedDependencies.get(currentProject.packageName)?.add(dependencyName); + const nextProject: RushConfigurationProject | undefined = + rushConfiguration.getProjectByName(dependencyName); + if (nextProject) { + processTransitiveInjectedInstallQueue.push(nextProject); } } } diff --git a/libraries/rush-lib/src/logic/setup/ArtifactoryConfiguration.ts b/libraries/rush-lib/src/logic/setup/ArtifactoryConfiguration.ts index fc72e437b4b..effb502a5c9 100644 --- a/libraries/rush-lib/src/logic/setup/ArtifactoryConfiguration.ts +++ b/libraries/rush-lib/src/logic/setup/ArtifactoryConfiguration.ts @@ -31,13 +31,13 @@ export interface IArtifactoryJson { packageRegistry: IArtifactoryPackageRegistryJson; } +const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); + /** * Use this class to load the "common/config/rush/artifactory.json" config file. * It configures the "rush setup" command. */ export class ArtifactoryConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - private readonly _jsonFileName: string; /** @@ -60,7 +60,7 @@ export class ArtifactoryConfiguration { }; if (FileSystem.exists(this._jsonFileName)) { - this.configuration = JsonFile.loadAndValidate(this._jsonFileName, ArtifactoryConfiguration._jsonSchema); + this.configuration = JsonFile.loadAndValidate(this._jsonFileName, _jsonSchema); if (!this.configuration.packageRegistry.credentialType) { this.configuration.packageRegistry.credentialType = 'password'; } diff --git a/libraries/rush-lib/src/logic/setup/SetupPackageRegistry.ts b/libraries/rush-lib/src/logic/setup/SetupPackageRegistry.ts index ffbabe9e18b..6ffa3504e6a 100644 --- a/libraries/rush-lib/src/logic/setup/SetupPackageRegistry.ts +++ b/libraries/rush-lib/src/logic/setup/SetupPackageRegistry.ts @@ -155,10 +155,10 @@ export class SetupPackageRegistry { } // NPM 6.x writes to stdout - let jsonContent: string | undefined = SetupPackageRegistry._tryFindJson(result.stdout); + let jsonContent: string | undefined = _tryFindJson(result.stdout); if (jsonContent === undefined) { // NPM 7.x writes dirty output to stderr; see https://github.com/npm/cli/issues/2740 - jsonContent = SetupPackageRegistry._tryFindJson(result.stderr); + jsonContent = _tryFindJson(result.stderr); } if (jsonContent === undefined) { throw new InternalError('The "npm view" command did not return a JSON structure'); @@ -352,7 +352,7 @@ export class SetupPackageRegistry { } // ...then append the stuff we got from the REST API, but discard any junk that isn't a proper key/value - linesToAdd.push(...responseLines.filter((x) => SetupPackageRegistry._getNpmrcKey(x) !== undefined)); + linesToAdd.push(...responseLines.filter((x) => _getNpmrcKey(x) !== undefined)); const npmrcPath: string = path.join(User.getHomeFolder(), '.npmrc'); @@ -385,7 +385,7 @@ export class SetupPackageRegistry { for (let index: number = 0; index < workingLinesToAdd.length; ++index) { const lineToAdd: string = workingLinesToAdd[index]!; - const key: string | undefined = SetupPackageRegistry._getNpmrcKey(lineToAdd); + const key: string | undefined = _getNpmrcKey(lineToAdd); if (key !== undefined) { // If there are duplicate keys, the first one takes precedence. // In particular this means "userNpmrcLinesToAdd" takes precedence over the REST API response @@ -418,7 +418,7 @@ export class SetupPackageRegistry { for (const npmrcLine of npmrcLines) { const trimmed: string = npmrcLine.trim(); if (trimmed.length > 0) { - if (SetupPackageRegistry._getNpmrcKey(trimmed) === undefined) { + if (_getNpmrcKey(trimmed) === undefined) { npmrcNonKeyLinesSet.add(trimmed); } } @@ -428,7 +428,7 @@ export class SetupPackageRegistry { for (let index: number = 0; index < npmrcLines.length; ++index) { const line: string = npmrcLines[index]; - const key: string | undefined = SetupPackageRegistry._getNpmrcKey(line); + const key: string | undefined = _getNpmrcKey(line); if (key) { const linesToAddIndex: number | undefined = keysToReplace.get(key); if (linesToAddIndex !== undefined) { @@ -463,73 +463,73 @@ export class SetupPackageRegistry { // Save the result FileSystem.writeFile(npmrcPath, npmrcLines.join('\n').trimRight() + '\n'); } +} - private static _getNpmrcKey(npmrcLine: string): string | undefined { - if (SetupPackageRegistry._isCommentLine(npmrcLine)) { - return undefined; - } - const delimiterIndex: number = npmrcLine.indexOf('='); - if (delimiterIndex < 1) { - return undefined; - } - const key: string = npmrcLine.substring(0, delimiterIndex + 1); - return key.trim(); +function _getNpmrcKey(npmrcLine: string): string | undefined { + if (_isCommentLine(npmrcLine)) { + return undefined; } - - private static _isCommentLine(npmrcLine: string): boolean { - return /^\s*#/.test(npmrcLine); + const delimiterIndex: number = npmrcLine.indexOf('='); + if (delimiterIndex < 1) { + return undefined; } + const key: string = npmrcLine.substring(0, delimiterIndex + 1); + return key.trim(); +} - /** - * This is a workaround for https://github.com/npm/cli/issues/2740 where the NPM tool sometimes - * mixes together JSON and terminal messages in a single STDERR stream. - * - * @remarks - * Given an input like this: - * ``` - * npm ERR! 404 Note that you can also install from a - * npm ERR! 404 tarball, folder, http url, or git url. - * { - * "error": { - * "code": "E404", - * "summary": "Not Found - GET https://registry.npmjs.org/@rushstack%2fnonexistent-package - Not found" - * } - * } - * npm ERR! A complete log of this run can be found in: - * ``` - * - * @returns the JSON section, or `undefined` if a JSON object could not be detected - */ - private static _tryFindJson(dirtyOutput: string): string | undefined { - const lines: string[] = Text.splitByNewLines(dirtyOutput); - let startIndex: number | undefined; - let endIndex: number | undefined; - - // Find the first line that starts with "{" - for (let i: number = 0; i < lines.length; ++i) { - const line: string = lines[i]; - if (/^\s*\{/.test(line)) { - startIndex = i; - break; - } - } - if (startIndex === undefined) { - return undefined; - } +function _isCommentLine(npmrcLine: string): boolean { + return /^\s*#/.test(npmrcLine); +} - // Find the last line that ends with "}" - for (let i: number = lines.length - 1; i >= startIndex; --i) { - const line: string = lines[i]; - if (/\}\s*$/.test(line)) { - endIndex = i; - break; - } +/** + * This is a workaround for https://github.com/npm/cli/issues/2740 where the NPM tool sometimes + * mixes together JSON and terminal messages in a single STDERR stream. + * + * @remarks + * Given an input like this: + * ``` + * npm ERR! 404 Note that you can also install from a + * npm ERR! 404 tarball, folder, http url, or git url. + * { + * "error": { + * "code": "E404", + * "summary": "Not Found - GET https://registry.npmjs.org/@rushstack%2fnonexistent-package - Not found" + * } + * } + * npm ERR! A complete log of this run can be found in: + * ``` + * + * @returns the JSON section, or `undefined` if a JSON object could not be detected + */ +function _tryFindJson(dirtyOutput: string): string | undefined { + const lines: string[] = Text.splitByNewLines(dirtyOutput); + let startIndex: number | undefined; + let endIndex: number | undefined; + + // Find the first line that starts with "{" + for (let i: number = 0; i < lines.length; ++i) { + const line: string = lines[i]; + if (/^\s*\{/.test(line)) { + startIndex = i; + break; } + } + if (startIndex === undefined) { + return undefined; + } - if (endIndex === undefined) { - return undefined; + // Find the last line that ends with "}" + for (let i: number = lines.length - 1; i >= startIndex; --i) { + const line: string = lines[i]; + if (/\}\s*$/.test(line)) { + endIndex = i; + break; } + } - return lines.slice(startIndex, endIndex + 1).join('\n'); + if (endIndex === undefined) { + return undefined; } + + return lines.slice(startIndex, endIndex + 1).join('\n'); } diff --git a/libraries/rush-lib/src/logic/setup/TerminalInput.ts b/libraries/rush-lib/src/logic/setup/TerminalInput.ts index 90c5ec56bcf..a73b4eda1f6 100644 --- a/libraries/rush-lib/src/logic/setup/TerminalInput.ts +++ b/libraries/rush-lib/src/logic/setup/TerminalInput.ts @@ -204,19 +204,6 @@ class PasswordKeyboardLoop extends KeyboardLoop { } export class TerminalInput { - private static async _readLineAsync(): Promise { - const readlineInterface: readline.Interface = readline.createInterface({ input: process.stdin }); - try { - return await new Promise((resolve, reject) => { - readlineInterface.question('', (answer: string) => { - resolve(answer); - }); - }); - } finally { - readlineInterface.close(); - } - } - public static async promptYesNoAsync(options: IPromptYesNoOptions): Promise { const keyboardLoop: YesNoKeyboardLoop = new YesNoKeyboardLoop(options); await keyboardLoop.startAsync(); @@ -228,7 +215,7 @@ export class TerminalInput { stderr.write(Colorize.green('==>') + ' '); stderr.write(Colorize.bold(options.message)); stderr.write(' '); - return await TerminalInput._readLineAsync(); + return await _readLineAsync(); } public static async promptPasswordLineAsync(options: IPromptLineOptions): Promise { @@ -237,3 +224,16 @@ export class TerminalInput { return keyboardLoop.result; } } + +async function _readLineAsync(): Promise { + const readlineInterface: readline.Interface = readline.createInterface({ input: process.stdin }); + try { + return await new Promise((resolve, reject) => { + readlineInterface.question('', (answer: string) => { + resolve(answer); + }); + }); + } finally { + readlineInterface.close(); + } +} diff --git a/libraries/rush-lib/src/logic/test/PublishUtilities.test.ts b/libraries/rush-lib/src/logic/test/PublishUtilities.test.ts index 5cca3f9f2b7..572fd3ed21b 100644 --- a/libraries/rush-lib/src/logic/test/PublishUtilities.test.ts +++ b/libraries/rush-lib/src/logic/test/PublishUtilities.test.ts @@ -8,7 +8,7 @@ import { Executable, type IWaitForExitResult } from '@rushstack/node-core-librar import { type IChangeInfo, ChangeType } from '../../api/ChangeManagement'; import { RushConfiguration } from '../../api/RushConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import { PublishUtilities, type IChangeRequests } from '../PublishUtilities'; +import { PublishUtilities, _updateCommitDetailsAsync, type IChangeRequests } from '../PublishUtilities'; import { ChangeFiles } from '../ChangeFiles'; import { Git } from '../Git'; @@ -138,7 +138,7 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => { createGitResult('commit 0123456789abcdef\nAuthor: Test Author \n') ); - await PublishUtilities['_updateCommitDetailsAsync'](git, changeFilePath, changes); + await _updateCommitDetailsAsync(git, changeFilePath, changes); expect(spawnSpy).toHaveBeenCalledWith(gitPath, ['log', '-n', '1', '--', changeFilePath], { currentWorkingDirectory: path.dirname(changeFilePath) @@ -168,7 +168,7 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => { createGitResult('commit 0123456789abcdef\nAuthor: Test Author \n') ); - await PublishUtilities['_updateCommitDetailsAsync'](git, changeFilePath, changes); + await _updateCommitDetailsAsync(git, changeFilePath, changes); expect(spawnSpy).toHaveBeenCalledWith(gitPath, ['log', '-n', '1', '--', changeFilePath], { currentWorkingDirectory: path.dirname(changeFilePath) @@ -198,7 +198,7 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => { ) ); - await PublishUtilities['_updateCommitDetailsAsync'](git, path.resolve('change.json'), changes); + await _updateCommitDetailsAsync(git, path.resolve('change.json'), changes); expect(changes).toEqual([{ packageName: 'd' }]); } diff --git a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts index 567f1678f95..f456dd81d99 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts @@ -78,7 +78,7 @@ export class VersionMismatchFinder { truncateLongPackageNameLists } = options ?? {}; - VersionMismatchFinder._checkForInconsistentVersions(rushConfiguration, { + _checkForInconsistentVersions(rushConfiguration, { variant, subspace, printAsJson, @@ -95,7 +95,7 @@ export class VersionMismatchFinder { ): void { const { variant, subspace = rushConfiguration.defaultSubspace } = options ?? {}; - VersionMismatchFinder._checkForInconsistentVersions(rushConfiguration, { + _checkForInconsistentVersions(rushConfiguration, { subspace, variant, terminal, @@ -129,62 +129,6 @@ export class VersionMismatchFinder { return new VersionMismatchFinder(projects, commonVersions.allowedAlternativeVersions); } - private static _checkForInconsistentVersions( - rushConfiguration: RushConfiguration, - options: { - isRushCheckCommand: boolean; - subspace: Subspace; - variant: string | undefined; - printAsJson?: boolean | undefined; - terminal: ITerminal; - truncateLongPackageNameLists?: boolean | undefined; - } - ): void { - const { variant, isRushCheckCommand, printAsJson, subspace, truncateLongPackageNameLists, terminal } = - options; - if (subspace.shouldEnsureConsistentVersions(variant) || isRushCheckCommand) { - const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( - rushConfiguration, - options - ); - - if (printAsJson) { - mismatchFinder.printAsJson(); - } else { - mismatchFinder.print(truncateLongPackageNameLists); - - if (mismatchFinder.numberOfMismatches > 0) { - // eslint-disable-next-line no-console - console.log( - Colorize.red( - `Found ${mismatchFinder.numberOfMismatches} mis-matching dependencies ${ - subspace?.subspaceName ? `in subspace: ${subspace?.subspaceName}` : '' - }` - ) - ); - rushConfiguration.customTipsConfiguration._showErrorTip( - terminal, - CustomTipId.TIP_RUSH_INCONSISTENT_VERSIONS - ); - if (!isRushCheckCommand && truncateLongPackageNameLists) { - // There isn't a --verbose flag in `rush install`/`rush update`, so a long list will always be truncated. - // eslint-disable-next-line no-console - console.log( - 'For more detailed reporting about these version mismatches, use the "rush check --verbose" command.' - ); - } - - throw new AlreadyReportedError(); - } else { - if (isRushCheckCommand) { - // eslint-disable-next-line no-console - console.log(Colorize.green(`Found no mis-matching dependencies!`)); - } - } - } - } - } - public get mismatches(): ReadonlyMap> { return this._mismatches; } @@ -351,3 +295,59 @@ export class VersionMismatchFinder { return keys; } } + +function _checkForInconsistentVersions( + rushConfiguration: RushConfiguration, + options: { + isRushCheckCommand: boolean; + subspace: Subspace; + variant: string | undefined; + printAsJson?: boolean | undefined; + terminal: ITerminal; + truncateLongPackageNameLists?: boolean | undefined; + } +): void { + const { variant, isRushCheckCommand, printAsJson, subspace, truncateLongPackageNameLists, terminal } = + options; + if (subspace.shouldEnsureConsistentVersions(variant) || isRushCheckCommand) { + const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( + rushConfiguration, + options + ); + + if (printAsJson) { + mismatchFinder.printAsJson(); + } else { + mismatchFinder.print(truncateLongPackageNameLists); + + if (mismatchFinder.numberOfMismatches > 0) { + // eslint-disable-next-line no-console + console.log( + Colorize.red( + `Found ${mismatchFinder.numberOfMismatches} mis-matching dependencies ${ + subspace?.subspaceName ? `in subspace: ${subspace?.subspaceName}` : '' + }` + ) + ); + rushConfiguration.customTipsConfiguration._showErrorTip( + terminal, + CustomTipId.TIP_RUSH_INCONSISTENT_VERSIONS + ); + if (!isRushCheckCommand && truncateLongPackageNameLists) { + // There isn't a --verbose flag in `rush install`/`rush update`, so a long list will always be truncated. + // eslint-disable-next-line no-console + console.log( + 'For more detailed reporting about these version mismatches, use the "rush check --verbose" command.' + ); + } + + throw new AlreadyReportedError(); + } else { + if (isRushCheckCommand) { + // eslint-disable-next-line no-console + console.log(Colorize.green(`Found no mis-matching dependencies!`)); + } + } + } + } +} diff --git a/libraries/rush-lib/src/logic/yarn/YarnShrinkwrapFile.ts b/libraries/rush-lib/src/logic/yarn/YarnShrinkwrapFile.ts index a3dcbfd27b0..920c72df3d4 100644 --- a/libraries/rush-lib/src/logic/yarn/YarnShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/yarn/YarnShrinkwrapFile.ts @@ -88,6 +88,11 @@ interface IYarnShrinkwrapJson { [packageNameAndSemVer: string]: IYarnShrinkwrapEntry; } +// Example inputs: +// "js-tokens@^3.0.0 || ^4.0.0" +// "@rush-temp/api-extractor-test-03@file:./projects/api-extractor-test-03.tgz" +const _packageNameAndSemVerRegExp: RegExp = /^(@?[^@\s]+)(?:@(.*))?$/; + /** * Support for consuming the "yarn.lock" file. * @@ -102,11 +107,6 @@ interface IYarnShrinkwrapJson { export class YarnShrinkwrapFile extends BaseShrinkwrapFile { public readonly isWorkspaceCompatible: boolean; - // Example inputs: - // "js-tokens@^3.0.0 || ^4.0.0" - // "@rush-temp/api-extractor-test-03@file:./projects/api-extractor-test-03.tgz" - private static _packageNameAndSemVerRegExp: RegExp = /^(@?[^@\s]+)(?:@(.*))?$/; - private _shrinkwrapJson: IYarnShrinkwrapJson; private _tempProjectNames: string[]; @@ -119,7 +119,7 @@ export class YarnShrinkwrapFile extends BaseShrinkwrapFile { for (const key of Object.keys(this._shrinkwrapJson)) { // Example key: - const packageNameAndSemVer: IPackageNameAndSemVer = YarnShrinkwrapFile._decodePackageNameAndSemVer(key); + const packageNameAndSemVer: IPackageNameAndSemVer = _decodePackageNameAndSemVer(key); // If it starts with @rush-temp, then include it: if ( @@ -188,51 +188,6 @@ export class YarnShrinkwrapFile extends BaseShrinkwrapFile { return new YarnShrinkwrapFile(shrinkwrapJson.object); } - /** - * The `@yarnpkg/lockfile` API only partially deserializes its data, and expects the caller - * to parse the yarn.lock lookup keys (sometimes called a "pattern"). - * - * Example input: "js-tokens@^3.0.0 || ^4.0.0" - * Example output: { packageName: "js-tokens", semVerRange: "^3.0.0 || ^4.0.0" } - */ - private static _decodePackageNameAndSemVer(packageNameAndSemVer: string): IPackageNameAndSemVer { - const result: RegExpExecArray | null = - YarnShrinkwrapFile._packageNameAndSemVerRegExp.exec(packageNameAndSemVer); - if (!result) { - // Sanity check -- this should never happen - throw new Error( - 'Unable to parse package/semver expression in the Yarn shrinkwrap file (yarn.lock): ' + - JSON.stringify(packageNameAndSemVer) - ); - } - - const packageName: string = result[1] || ''; - const parsedPackageName: IParsedPackageNameOrError = PackageNameParsers.permissive.tryParse(packageName); - if (parsedPackageName.error) { - // Sanity check -- this should never happen - throw new Error( - 'Invalid package name the Yarn shrinkwrap file (yarn.lock): ' + - JSON.stringify(packageNameAndSemVer) + - '\n' + - parsedPackageName.error - ); - } - - return { - packageName, - semVerRange: result[2] || '' - }; - } - - /** - * This is the inverse of _decodePackageNameAndSemVer(): - * Given an IPackageNameAndSemVer object, recreate the yarn.lock lookup key - * (sometimes called a "pattern"). - */ - private static _encodePackageNameAndSemVer(packageNameAndSemVer: IPackageNameAndSemVer): string { - return packageNameAndSemVer.packageName + '@' + packageNameAndSemVer.semVerRange; - } - public override getTempProjectNames(): ReadonlyArray { return this._tempProjectNames; } @@ -240,7 +195,7 @@ export class YarnShrinkwrapFile extends BaseShrinkwrapFile { public override hasCompatibleTopLevelDependency(dependencySpecifier: DependencySpecifier): boolean { // It seems like we should normalize the key somehow, but Yarn apparently does not // do any normalization. - const key: string = YarnShrinkwrapFile._encodePackageNameAndSemVer({ + const key: string = _encodePackageNameAndSemVer({ packageName: dependencySpecifier.packageName, semVerRange: dependencySpecifier.versionSpecifier }); @@ -284,3 +239,47 @@ export class YarnShrinkwrapFile extends BaseShrinkwrapFile { throw new InternalError('Not implemented'); } } + +/** + * The `@yarnpkg/lockfile` API only partially deserializes its data, and expects the caller + * to parse the yarn.lock lookup keys (sometimes called a "pattern"). + * + * Example input: "js-tokens@^3.0.0 || ^4.0.0" + * Example output: { packageName: "js-tokens", semVerRange: "^3.0.0 || ^4.0.0" } + */ +function _decodePackageNameAndSemVer(packageNameAndSemVer: string): IPackageNameAndSemVer { + const result: RegExpExecArray | null = _packageNameAndSemVerRegExp.exec(packageNameAndSemVer); + if (!result) { + // Sanity check -- this should never happen + throw new Error( + 'Unable to parse package/semver expression in the Yarn shrinkwrap file (yarn.lock): ' + + JSON.stringify(packageNameAndSemVer) + ); + } + + const packageName: string = result[1] || ''; + const parsedPackageName: IParsedPackageNameOrError = PackageNameParsers.permissive.tryParse(packageName); + if (parsedPackageName.error) { + // Sanity check -- this should never happen + throw new Error( + 'Invalid package name the Yarn shrinkwrap file (yarn.lock): ' + + JSON.stringify(packageNameAndSemVer) + + '\n' + + parsedPackageName.error + ); + } + + return { + packageName, + semVerRange: result[2] || '' + }; +} + +/** + * This is the inverse of _decodePackageNameAndSemVer(): + * Given an IPackageNameAndSemVer object, recreate the yarn.lock lookup key + * (sometimes called a "pattern"). + */ +function _encodePackageNameAndSemVer(packageNameAndSemVer: IPackageNameAndSemVer): string { + return packageNameAndSemVer.packageName + '@' + packageNameAndSemVer.semVerRange; +} diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/RushSdk.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/RushSdk.ts index b69905f5fc6..472990c257c 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/RushSdk.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/RushSdk.ts @@ -6,18 +6,18 @@ declare const global: typeof globalThis & { ___rush___rushLibModule?: RushLibModuleType; }; -export class RushSdk { - private static _initialized: boolean = false; +let _initialized: boolean = false; +export class RushSdk { public static ensureInitialized(): void { - if (!RushSdk._initialized) { + if (!_initialized) { const rushLibModule: RushLibModuleType = require('../../index'); // The "@rushstack/rush-sdk" shim will look for this global variable to obtain // Rush's instance of "@microsoft/rush-lib". global.___rush___rushLibModule = rushLibModule; - RushSdk._initialized = true; + _initialized = true; } } } diff --git a/libraries/rush-lib/src/utilities/PnpmSyncUtilities.ts b/libraries/rush-lib/src/utilities/PnpmSyncUtilities.ts index 1c443374339..58c0e3bacd7 100644 --- a/libraries/rush-lib/src/utilities/PnpmSyncUtilities.ts +++ b/libraries/rush-lib/src/utilities/PnpmSyncUtilities.ts @@ -14,13 +14,6 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; import { RushConstants } from '../logic/RushConstants'; export class PnpmSyncUtilities { - private static _addLinePrefix(message: string): string { - return message - .split('\n') - .map((x) => (x.trim() ? Colorize.cyan(`pnpm-sync: `) + x : x)) - .join('\n'); - } - public static processLogMessage(options: ILogMessageCallbackOptions, terminal: ITerminal): void { const message: string = options.message; const details: LogMessageDetails = options.details; @@ -29,7 +22,7 @@ export class PnpmSyncUtilities { switch (details.messageIdentifier) { case LogMessageIdentifier.PREPARE_FINISHING: terminal.writeVerboseLine( - PnpmSyncUtilities._addLinePrefix( + _addLinePrefix( `Regenerated ${RushConstants.pnpmSyncFilename} in ${Math.round(details.executionTimeInMs)} ms` ) ); @@ -42,7 +35,7 @@ export class PnpmSyncUtilities { (details.fileCount === 1 ? 'file' : 'files') + ` in ${Math.round(details.executionTimeInMs)} ms`; - terminal.writeVerboseLine(PnpmSyncUtilities._addLinePrefix(customMessage)); + terminal.writeVerboseLine(_addLinePrefix(customMessage)); } return; @@ -52,21 +45,21 @@ export class PnpmSyncUtilities { `Expecting ${RushConstants.pnpmSyncFilename} version ${details.expectedVersion}, ` + `but found version ${details.actualVersion}`; - terminal.writeVerboseLine(PnpmSyncUtilities._addLinePrefix(message)); - terminal.writeVerboseLine(PnpmSyncUtilities._addLinePrefix(customMessage)); + terminal.writeVerboseLine(_addLinePrefix(message)); + terminal.writeVerboseLine(_addLinePrefix(customMessage)); } return; case LogMessageIdentifier.COPY_ERROR_INCOMPATIBLE_SYNC_FILE: { terminal.writeErrorLine( - PnpmSyncUtilities._addLinePrefix( + _addLinePrefix( `The workspace was installed using an incompatible version of pnpm-sync.\n` + `Please run "rush install" or "rush update" again.` ) ); terminal.writeLine( - PnpmSyncUtilities._addLinePrefix( + _addLinePrefix( `Expecting ${RushConstants.pnpmSyncFilename} version ${details.expectedVersion}, ` + `but found version ${details.actualVersion}\n` + `Affected folder: ${details.pnpmSyncJsonPath}` @@ -88,8 +81,15 @@ export class PnpmSyncUtilities { case LogMessageKind.INFO: case LogMessageKind.VERBOSE: - terminal.writeDebugLine(PnpmSyncUtilities._addLinePrefix(message)); + terminal.writeDebugLine(_addLinePrefix(message)); return; } } } + +function _addLinePrefix(message: string): string { + return message + .split('\n') + .map((x) => (x.trim() ? Colorize.cyan(`pnpm-sync: `) + x : x)) + .join('\n'); +} diff --git a/libraries/rush-lib/src/utilities/RushAlerts.ts b/libraries/rush-lib/src/utilities/RushAlerts.ts index ee7901db893..abca0c70577 100644 --- a/libraries/rush-lib/src/utilities/RushAlerts.ts +++ b/libraries/rush-lib/src/utilities/RushAlerts.ts @@ -271,14 +271,6 @@ export class RushAlerts { return alertsSortedByPriority[0]; } - private static _parseDate(dateString: string): Date { - const parsedDate: Date = new Date(dateString); - if (isNaN(parsedDate.getTime())) { - throw new Error(`Invalid date/time value ${JSON.stringify(dateString)}`); - } - return parsedDate; - } - private _isSnoozing(alertState: IRushAlertStateEntry): boolean { return ( Boolean(alertState.snooze) && @@ -290,14 +282,14 @@ export class RushAlerts { const timeNow: Date = new Date(); if (alert.startTime) { - const startTime: Date = RushAlerts._parseDate(alert.startTime); + const startTime: Date = _parseDate(alert.startTime); if (timeNow < startTime) { return false; } } if (alert.endTime) { - const endTime: Date = RushAlerts._parseDate(alert.endTime); + const endTime: Date = _parseDate(alert.endTime); if (timeNow > endTime) { return false; } @@ -429,3 +421,11 @@ export class RushAlerts { }); } } + +function _parseDate(dateString: string): Date { + const parsedDate: Date = new Date(dateString); + if (isNaN(parsedDate.getTime())) { + throw new Error(`Invalid date/time value ${JSON.stringify(dateString)}`); + } + return parsedDate; +} diff --git a/libraries/rush-lib/src/utilities/TarExecutable.ts b/libraries/rush-lib/src/utilities/TarExecutable.ts index 3d6bc08cecf..6ea2e0f4d61 100644 --- a/libraries/rush-lib/src/utilities/TarExecutable.ts +++ b/libraries/rush-lib/src/utilities/TarExecutable.ts @@ -37,7 +37,7 @@ export class TarExecutable { public static async tryInitializeAsync(terminal: ITerminal): Promise { terminal.writeVerboseLine('Trying to find "tar" binary'); const tarExecutablePath: string | undefined = - EnvironmentConfiguration.tarBinaryPath || (await TarExecutable._tryFindTarExecutablePathAsync()); + EnvironmentConfiguration.tarBinaryPath || (await _tryFindTarExecutablePathAsync()); if (!tarExecutablePath) { terminal.writeVerboseLine('"tar" was not found on the PATH'); return undefined; @@ -173,22 +173,22 @@ export class TarExecutable { return tarExitCode; } +} - private static async _tryFindTarExecutablePathAsync(): Promise { - if (IS_WINDOWS) { - // If we're running on Windows, first try to use the OOB tar executable. If - // we're running in the Git Bash, the tar executable on the PATH doesn't handle - // Windows file paths correctly. - // eslint-disable-next-line dot-notation - const windowsFolderPath: string | undefined = process.env['WINDIR']; - if (windowsFolderPath) { - const defaultWindowsTarExecutablePath: string = `${windowsFolderPath}\\system32\\tar.exe`; - if (await FileSystem.existsAsync(defaultWindowsTarExecutablePath)) { - return defaultWindowsTarExecutablePath; - } +async function _tryFindTarExecutablePathAsync(): Promise { + if (IS_WINDOWS) { + // If we're running on Windows, first try to use the OOB tar executable. If + // we're running in the Git Bash, the tar executable on the PATH doesn't handle + // Windows file paths correctly. + // eslint-disable-next-line dot-notation + const windowsFolderPath: string | undefined = process.env['WINDIR']; + if (windowsFolderPath) { + const defaultWindowsTarExecutablePath: string = `${windowsFolderPath}\\system32\\tar.exe`; + if (await FileSystem.existsAsync(defaultWindowsTarExecutablePath)) { + return defaultWindowsTarExecutablePath; } } - - return Executable.tryResolve('tar'); } + + return Executable.tryResolve('tar'); } diff --git a/libraries/rush-lib/src/utilities/Utilities.ts b/libraries/rush-lib/src/utilities/Utilities.ts index 6fb93957f3b..1b4ca37cc41 100644 --- a/libraries/rush-lib/src/utilities/Utilities.ts +++ b/libraries/rush-lib/src/utilities/Utilities.ts @@ -357,7 +357,7 @@ export class Utilities { keepEnvironment, captureExitCodeAndSignal } = options; - const { exitCode, signal } = await Utilities._executeCommandInternalAsync({ + const { exitCode, signal } = await _executeCommandInternalAsync({ command, args, workingDirectory, @@ -398,7 +398,7 @@ export class Utilities { public static async executeCommandAndCaptureOutputAsync( options: IExecuteCommandAndCaptureOutputOptions ): Promise> { - const result: IWaitForExitResult = await Utilities._executeCommandInternalAsync({ + const result: IWaitForExitResult = await _executeCommandInternalAsync({ ...options, stdio: ['pipe', 'pipe', 'pipe'], captureOutput: true @@ -463,11 +463,14 @@ export class Utilities { * @param options - options for how the command should be run */ public static executeLifecycleCommand(command: string, options: ILifecycleCommandOptions): number { - const result: child_process.SpawnSyncReturns = - Utilities._executeLifecycleCommandInternal(command, child_process.spawnSync, options); + const result: child_process.SpawnSyncReturns = _executeLifecycleCommandInternal( + command, + child_process.spawnSync, + options + ); if (options.handleOutput) { - Utilities._processResult({ + _processResult({ error: result.error, status: result.status, stderr: result.stderr.toString() @@ -490,7 +493,7 @@ export class Utilities { command: string, options: ILifecycleCommandOptions ): child_process.ChildProcess { - const child: child_process.ChildProcess = Utilities._executeLifecycleCommandInternal( + const child: child_process.ChildProcess = _executeLifecycleCommandInternal( command, child_process.spawn, options @@ -554,7 +557,7 @@ export class Utilities { command: 'npm', args: ['install'], workingDirectory: directory, - environment: Utilities._createEnvironmentForRushCommand({}), + environment: _createEnvironmentForRushCommand({}), suppressOutput }, maxInstallAttempts @@ -656,263 +659,253 @@ export class Utilities { args: [...commandFlags, commandToRun] }; } +} - private static _executeLifecycleCommandInternal( - commandAndArgs: string, - spawnFunction: ( - command: string, - args: string[], - spawnOptions: child_process.SpawnOptions - ) => TCommandResult, - options: ILifecycleCommandOptions - ): TCommandResult { - const { - initCwd, - initialEnvironment, - environmentPathOptions, - rushConfiguration, - workingDirectory, - handleOutput, - ipc, - connectSubprocessTerminator - } = options; - const environment: IEnvironment = Utilities._createEnvironmentForRushCommand({ - initCwd, - initialEnvironment, - pathOptions: { - ...environmentPathOptions, - rushJsonFolder: rushConfiguration?.rushJsonFolder, - projectRoot: workingDirectory, - commonTempFolder: rushConfiguration ? rushConfiguration.commonTempFolder : undefined - } - }); - - const stdio: child_process.StdioOptions = handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2]; - if (ipc) { - stdio.push('ipc'); - } - - const spawnOptions: child_process.SpawnOptions = { - cwd: workingDirectory, - env: environment, - stdio - }; - - if (connectSubprocessTerminator) { - Object.assign(spawnOptions, SubprocessTerminator.RECOMMENDED_OPTIONS); - } +function _executeLifecycleCommandInternal( + commandAndArgs: string, + spawnFunction: ( + command: string, + args: string[], + spawnOptions: child_process.SpawnOptions + ) => TCommandResult, + options: ILifecycleCommandOptions +): TCommandResult { + const { + initCwd, + initialEnvironment, + environmentPathOptions, + rushConfiguration, + workingDirectory, + handleOutput, + ipc, + connectSubprocessTerminator + } = options; + const environment: IEnvironment = _createEnvironmentForRushCommand({ + initCwd, + initialEnvironment, + pathOptions: { + ...environmentPathOptions, + rushJsonFolder: rushConfiguration?.rushJsonFolder, + projectRoot: workingDirectory, + commonTempFolder: rushConfiguration ? rushConfiguration.commonTempFolder : undefined + } + }); + + const stdio: child_process.StdioOptions = handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2]; + if (ipc) { + stdio.push('ipc'); + } - const { command, args } = Utilities._convertCommandAndArgsToShell(commandAndArgs); + const spawnOptions: child_process.SpawnOptions = { + cwd: workingDirectory, + env: environment, + stdio + }; - if (IS_WINDOWS) { - const shellCommand: string = [command, ...args].join(' '); - return spawnFunction(shellCommand, [], { ...spawnOptions, shell: true }); - } else { - return spawnFunction(command, args, spawnOptions); - } + if (connectSubprocessTerminator) { + Object.assign(spawnOptions, SubprocessTerminator.RECOMMENDED_OPTIONS); } - /** - * Returns a process.env environment suitable for executing lifecycle scripts. - * @param initialEnvironment - an existing environment to copy instead of process.env - * - * @remarks - * Rush._assignRushInvokedFolder() assigns the `RUSH_INVOKED_FOLDER` variable globally - * via the parent process's environment. - */ - private static _createEnvironmentForRushCommand( - options: ICreateEnvironmentForRushCommandOptions - ): IEnvironment { - if (options.initialEnvironment === undefined) { - options.initialEnvironment = process.env; - } + const { command, args } = Utilities._convertCommandAndArgsToShell(commandAndArgs); - // Set some defaults for the environment - const environment: IEnvironment = {}; - if (options.pathOptions?.rushJsonFolder) { - environment.RUSHSTACK_FILE_ERROR_BASE_FOLDER = options.pathOptions.rushJsonFolder; - } + if (IS_WINDOWS) { + const shellCommand: string = [command, ...args].join(' '); + return spawnFunction(shellCommand, [], { ...spawnOptions, shell: true }); + } else { + return spawnFunction(command, args, spawnOptions); + } +} - for (const key of Object.getOwnPropertyNames(options.initialEnvironment)) { - const normalizedKey: string = IS_WINDOWS ? key.toUpperCase() : key; +/** + * Returns a process.env environment suitable for executing lifecycle scripts. + * @param initialEnvironment - an existing environment to copy instead of process.env + * + * @remarks + * Rush._assignRushInvokedFolder() assigns the `RUSH_INVOKED_FOLDER` variable globally + * via the parent process's environment. + */ +function _createEnvironmentForRushCommand(options: ICreateEnvironmentForRushCommandOptions): IEnvironment { + if (options.initialEnvironment === undefined) { + options.initialEnvironment = process.env; + } - // If Rush itself was invoked inside a lifecycle script, this may be set and would interfere - // with Rush's installations. If we actually want it, we will set it explicitly below. - if (normalizedKey === 'INIT_CWD') { - continue; - } + // Set some defaults for the environment + const environment: IEnvironment = {}; + if (options.pathOptions?.rushJsonFolder) { + environment.RUSHSTACK_FILE_ERROR_BASE_FOLDER = options.pathOptions.rushJsonFolder; + } - // When NPM invokes a lifecycle event, it copies its entire configuration into environment - // variables. Rush is supposed to be a deterministic controlled environment, so don't bring - // this along. - // - // NOTE: Longer term we should clean out the entire environment and use rush.json to bring - // back specific environment variables that the repo maintainer has determined to be safe. - if (normalizedKey.match(/^NPM_CONFIG_/)) { - continue; - } + for (const key of Object.getOwnPropertyNames(options.initialEnvironment)) { + const normalizedKey: string = IS_WINDOWS ? key.toUpperCase() : key; - // Use the uppercased environment variable name on Windows because environment variable names - // are case-insensitive on Windows - environment[normalizedKey] = options.initialEnvironment[key]; + // If Rush itself was invoked inside a lifecycle script, this may be set and would interfere + // with Rush's installations. If we actually want it, we will set it explicitly below. + if (normalizedKey === 'INIT_CWD') { + continue; } - // When NPM invokes a lifecycle script, it sets an environment variable INIT_CWD that remembers - // the directory that NPM started in. This allows naive scripts to change their current working directory - // and invoke NPM operations, while still be able to find a local .npmrc file. Although Rush recommends - // for toolchain scripts to be professionally written (versus brittle stuff like - // "cd ./lib && npm run tsc && cd .."), we support INIT_CWD for compatibility. + // When NPM invokes a lifecycle event, it copies its entire configuration into environment + // variables. Rush is supposed to be a deterministic controlled environment, so don't bring + // this along. // - // More about this feature: https://github.com/npm/npm/pull/12356 - if (options.initCwd) { - environment['INIT_CWD'] = options.initCwd; // eslint-disable-line dot-notation + // NOTE: Longer term we should clean out the entire environment and use rush.json to bring + // back specific environment variables that the repo maintainer has determined to be safe. + if (normalizedKey.match(/^NPM_CONFIG_/)) { + continue; } - if (options.pathOptions) { - if (options.pathOptions.includeRepoBin && options.pathOptions.commonTempFolder) { - environment.PATH = Utilities._prependNodeModulesBinToPath( - environment.PATH, - options.pathOptions.commonTempFolder - ); - } + // Use the uppercased environment variable name on Windows because environment variable names + // are case-insensitive on Windows + environment[normalizedKey] = options.initialEnvironment[key]; + } - if (options.pathOptions.includeProjectBin && options.pathOptions.projectRoot) { - environment.PATH = Utilities._prependNodeModulesBinToPath( - environment.PATH, - options.pathOptions.projectRoot - ); - } + // When NPM invokes a lifecycle script, it sets an environment variable INIT_CWD that remembers + // the directory that NPM started in. This allows naive scripts to change their current working directory + // and invoke NPM operations, while still be able to find a local .npmrc file. Although Rush recommends + // for toolchain scripts to be professionally written (versus brittle stuff like + // "cd ./lib && npm run tsc && cd .."), we support INIT_CWD for compatibility. + // + // More about this feature: https://github.com/npm/npm/pull/12356 + if (options.initCwd) { + environment['INIT_CWD'] = options.initCwd; // eslint-disable-line dot-notation + } - if (options.pathOptions.additionalPathFolders) { - environment.PATH = [...options.pathOptions.additionalPathFolders, environment.PATH].join( - path.delimiter - ); - } + if (options.pathOptions) { + if (options.pathOptions.includeRepoBin && options.pathOptions.commonTempFolder) { + environment.PATH = _prependNodeModulesBinToPath(environment.PATH, options.pathOptions.commonTempFolder); } - // Communicate to downstream calls that they should not try to run hooks - environment[EnvironmentVariableNames._RUSH_RECURSIVE_RUSHX_CALL] = '1'; - - return environment; - } + if (options.pathOptions.includeProjectBin && options.pathOptions.projectRoot) { + environment.PATH = _prependNodeModulesBinToPath(environment.PATH, options.pathOptions.projectRoot); + } - /** - * Prepend the node_modules/.bin folder under the specified folder to the specified PATH variable. For example, - * if `rootDirectory` is "/foobar" and `existingPath` is "/bin", this function will return - * "/foobar/node_modules/.bin:/bin" - */ - private static _prependNodeModulesBinToPath( - existingPath: string | undefined, - rootDirectory: string - ): string { - const binPath: string = path.resolve(rootDirectory, 'node_modules', '.bin'); - if (existingPath) { - return `${binPath}${path.delimiter}${existingPath}`; - } else { - return binPath; + if (options.pathOptions.additionalPathFolders) { + environment.PATH = [...options.pathOptions.additionalPathFolders, environment.PATH].join( + path.delimiter + ); } } - /** - * Executes the command with the specified command-line parameters, and waits for it to complete. - * The current directory will be set to the specified workingDirectory. - */ - private static async _executeCommandInternalAsync( - options: IExecuteCommandInternalOptions & { captureOutput: true } - ): Promise>; - /** - * Executes the command with the specified command-line parameters, and waits for it to complete. - * The current directory will be set to the specified workingDirectory. This does not capture output. - */ - private static async _executeCommandInternalAsync( - options: IExecuteCommandInternalOptions & { captureOutput: false | undefined } - ): Promise; - private static async _executeCommandInternalAsync({ - command, - args, - workingDirectory, - stdio, - environment, - keepEnvironment, - onStdoutStreamChunk, - captureOutput, - captureExitCodeAndSignal - }: IExecuteCommandInternalOptions): Promise | IWaitForExitResultWithoutOutput> { - const spawnOptions: child_process.SpawnSyncOptions = { - cwd: workingDirectory, - shell: true, - stdio: stdio, - env: keepEnvironment - ? environment - : Utilities._createEnvironmentForRushCommand({ initialEnvironment: environment }), - maxBuffer: 10 * 1024 * 1024 // Set default max buffer size to 10MB - }; + // Communicate to downstream calls that they should not try to run hooks + environment[EnvironmentVariableNames._RUSH_RECURSIVE_RUSHX_CALL] = '1'; - // This is needed since we specify shell=true below. - // NOTE: On Windows if we escape "NPM", the spawnSync() function runs something like this: - // [ 'C:\\Windows\\system32\\cmd.exe', '/s', '/c', '""NPM" "install""' ] - // - // Due to a bug with Windows cmd.exe, the npm.cmd batch file's "%~dp0" variable will - // return the current working directory instead of the batch file's directory. - // The workaround is to not escape, npm, i.e. do this instead: - // [ 'C:\\Windows\\system32\\cmd.exe', '/s', '/c', '"npm "install""' ] - // - // We will come up with a better solution for this when we promote executeCommand() - // into node-core-library, but for now this hack will unblock people: - - // Only escape the command if it actually contains spaces: - const escapedCommand: string = escapeArgumentIfNeeded(command); - - const escapedArgs: string[] = args.map((x) => escapeArgumentIfNeeded(x)); - const shellCommand: string = [escapedCommand, ...escapedArgs].join(' '); - - const childProcess: child_process.ChildProcess = child_process.spawn(shellCommand, spawnOptions); - - if (onStdoutStreamChunk) { - const inspectStream: Transform = new Transform({ - transform: onStdoutStreamChunk - ? ( - chunk: string | Buffer, - encoding: BufferEncoding, - callback: (error?: Error, data?: string | Buffer) => void - ) => { - const chunkString: string = chunk.toString(); - const updatedChunk: string | void = onStdoutStreamChunk(chunkString); - callback(undefined, updatedChunk ?? chunk); - } - : undefined - }); + return environment; +} - childProcess.stdout?.pipe(inspectStream).pipe(process.stdout); - } +/** + * Prepend the node_modules/.bin folder under the specified folder to the specified PATH variable. For example, + * if `rootDirectory` is "/foobar" and `existingPath` is "/bin", this function will return + * "/foobar/node_modules/.bin:/bin" + */ +function _prependNodeModulesBinToPath(existingPath: string | undefined, rootDirectory: string): string { + const binPath: string = path.resolve(rootDirectory, 'node_modules', '.bin'); + if (existingPath) { + return `${binPath}${path.delimiter}${existingPath}`; + } else { + return binPath; + } +} - return await Executable.waitForExitAsync(childProcess, { - encoding: captureOutput ? 'utf8' : undefined, - throwOnNonZeroExitCode: !captureExitCodeAndSignal, - throwOnSignal: !captureExitCodeAndSignal +/** + * Executes the command with the specified command-line parameters, and waits for it to complete. + * The current directory will be set to the specified workingDirectory. + */ +async function _executeCommandInternalAsync( + options: IExecuteCommandInternalOptions & { captureOutput: true } +): Promise>; +/** + * Executes the command with the specified command-line parameters, and waits for it to complete. + * The current directory will be set to the specified workingDirectory. This does not capture output. + */ +async function _executeCommandInternalAsync( + options: IExecuteCommandInternalOptions & { captureOutput: false | undefined } +): Promise; +async function _executeCommandInternalAsync({ + command, + args, + workingDirectory, + stdio, + environment, + keepEnvironment, + onStdoutStreamChunk, + captureOutput, + captureExitCodeAndSignal +}: IExecuteCommandInternalOptions): Promise | IWaitForExitResultWithoutOutput> { + const spawnOptions: child_process.SpawnSyncOptions = { + cwd: workingDirectory, + shell: true, + stdio: stdio, + env: keepEnvironment + ? environment + : _createEnvironmentForRushCommand({ initialEnvironment: environment }), + maxBuffer: 10 * 1024 * 1024 // Set default max buffer size to 10MB + }; + + // This is needed since we specify shell=true below. + // NOTE: On Windows if we escape "NPM", the spawnSync() function runs something like this: + // [ 'C:\\Windows\\system32\\cmd.exe', '/s', '/c', '""NPM" "install""' ] + // + // Due to a bug with Windows cmd.exe, the npm.cmd batch file's "%~dp0" variable will + // return the current working directory instead of the batch file's directory. + // The workaround is to not escape, npm, i.e. do this instead: + // [ 'C:\\Windows\\system32\\cmd.exe', '/s', '/c', '"npm "install""' ] + // + // We will come up with a better solution for this when we promote executeCommand() + // into node-core-library, but for now this hack will unblock people: + + // Only escape the command if it actually contains spaces: + const escapedCommand: string = escapeArgumentIfNeeded(command); + + const escapedArgs: string[] = args.map((x) => escapeArgumentIfNeeded(x)); + const shellCommand: string = [escapedCommand, ...escapedArgs].join(' '); + + const childProcess: child_process.ChildProcess = child_process.spawn(shellCommand, spawnOptions); + + if (onStdoutStreamChunk) { + const inspectStream: Transform = new Transform({ + transform: onStdoutStreamChunk + ? ( + chunk: string | Buffer, + encoding: BufferEncoding, + callback: (error?: Error, data?: string | Buffer) => void + ) => { + const chunkString: string = chunk.toString(); + const updatedChunk: string | void = onStdoutStreamChunk(chunkString); + callback(undefined, updatedChunk ?? chunk); + } + : undefined }); - } - private static _processResult({ - error, - stderr, - status - }: { - error: Error | undefined; - stderr: string; - status: number | null; - }): void { - if (error) { - error.message += `\n${stderr}`; - if (status) { - error.message += `\nExited with status ${status}`; - } + childProcess.stdout?.pipe(inspectStream).pipe(process.stdout); + } - throw error; - } + return await Executable.waitForExitAsync(childProcess, { + encoding: captureOutput ? 'utf8' : undefined, + throwOnNonZeroExitCode: !captureExitCodeAndSignal, + throwOnSignal: !captureExitCodeAndSignal + }); +} +function _processResult({ + error, + stderr, + status +}: { + error: Error | undefined; + stderr: string; + // eslint-disable-next-line @rushstack/no-new-null + status: number | null; +}): void { + if (error) { + error.message += `\n${stderr}`; if (status) { - throw new Error(`The command failed with exit code ${status}\n${stderr}`); + error.message += `\nExited with status ${status}`; } + + throw error; + } + + if (status) { + throw new Error(`The command failed with exit code ${status}\n${stderr}`); } } diff --git a/libraries/rush-sdk/src/loader.ts b/libraries/rush-sdk/src/loader.ts index c3357278664..5b90e10788e 100644 --- a/libraries/rush-sdk/src/loader.ts +++ b/libraries/rush-sdk/src/loader.ts @@ -96,33 +96,6 @@ export interface ILoadSdkAsyncOptions { * @public */ export class RushSdkLoader { - /** - * Throws an "AbortError" exception if abortSignal.aborted is true. - */ - private static _checkForCancel( - abortSignal: AbortSignal, - onNotifyEvent: SdkNotifyEventCallback | undefined, - progressPercent: number | undefined - ): void { - if (!abortSignal?.aborted) { - return; - } - - if (onNotifyEvent) { - onNotifyEvent({ - logMessage: { - kind: 'info', - text: `The operation was canceled` - }, - progressPercent - }); - } - - const error: Error = new Error('The operation was canceled'); - error.name = 'AbortError'; - throw error; - } - /** * Returns true if the Rush engine has already been loaded. */ @@ -231,7 +204,7 @@ export class RushSdkLoader { } if (abortSignal) { - RushSdkLoader._checkForCancel(abortSignal, onNotifyEvent, progressPercent); + _checkForCancel(abortSignal, onNotifyEvent, progressPercent); } // TODO: Implement incremental progress updates @@ -285,3 +258,30 @@ export class RushSdkLoader { } } } + +/** + * Throws an "AbortError" exception if abortSignal.aborted is true. + */ +function _checkForCancel( + abortSignal: AbortSignal, + onNotifyEvent: SdkNotifyEventCallback | undefined, + progressPercent: number | undefined +): void { + if (!abortSignal?.aborted) { + return; + } + + if (onNotifyEvent) { + onNotifyEvent({ + logMessage: { + kind: 'info', + text: `The operation was canceled` + }, + progressPercent + }); + } + + const error: Error = new Error('The operation was canceled'); + error.name = 'AbortError'; + throw error; +} diff --git a/libraries/rushell/src/ParseError.ts b/libraries/rushell/src/ParseError.ts index 1b3c0cc646b..6819956a9ea 100644 --- a/libraries/rushell/src/ParseError.ts +++ b/libraries/rushell/src/ParseError.ts @@ -24,7 +24,7 @@ export class ParseError extends Error { public readonly innerError: Error | undefined; public constructor(message: string, range: TextRange, innerError?: Error) { - super(ParseError._formatMessage(message, range)); + super(_formatMessage(message, range)); // Boilerplate for extending a system class // @@ -38,25 +38,25 @@ export class ParseError extends Error { this.range = range; this.innerError = innerError; } +} - /** - * Generates a line/column prefix. Example with line=2 and column=5 - * and message="An error occurred": - * ``` - * "(2,5): An error occurred" - * ``` - */ - private static _formatMessage(message: string, range: TextRange): string { - if (!message) { - message = 'An unknown error occurred'; - } +/** + * Generates a line/column prefix. Example with line=2 and column=5 + * and message="An error occurred": + * ``` + * "(2,5): An error occurred" + * ``` + */ +function _formatMessage(message: string, range: TextRange): string { + if (!message) { + message = 'An unknown error occurred'; + } - if (range.pos !== 0 || range.end !== 0) { - const location: ITextLocation = range.getLocation(range.pos); - if (location.line) { - return `(${location.line},${location.column}): ` + message; - } + if (range.pos !== 0 || range.end !== 0) { + const location: ITextLocation = range.getLocation(range.pos); + if (location.line) { + return `(${location.line},${location.column}): ` + message; } - return message; } + return message; } diff --git a/libraries/rushell/src/Tokenizer.ts b/libraries/rushell/src/Tokenizer.ts index 7585249b1ed..269f4937785 100644 --- a/libraries/rushell/src/Tokenizer.ts +++ b/libraries/rushell/src/Tokenizer.ts @@ -74,14 +74,6 @@ export class Tokenizer { this._currentIndex = this.input.pos; } - private static _isSpace(c: string | undefined): boolean { - // You can empirically test whether shell treats a given character as whitespace like this: - // echo $(echo -e a '\u0009' b) - // If you get "a b" it means the tab character (Unicode 0009) is being collapsed away. - // If you get "a b" then the invisible character is being padded like a normal letter. - return c === ' ' || c === '\t'; - } - public get currentIndex(): number { return this._currentIndex; } @@ -98,10 +90,10 @@ export class Tokenizer { } // Is it a sequence of whitespace? - if (Tokenizer._isSpace(firstChar)) { + if (_isSpace(firstChar)) { this._readCharacter(); - while (Tokenizer._isSpace(this._peekCharacter())) { + while (_isSpace(this._peekCharacter())) { this._readCharacter(); } @@ -271,3 +263,11 @@ export class Tokenizer { return this.input.buffer[this._currentIndex + 1]; } } + +function _isSpace(c: string | undefined): boolean { + // You can empirically test whether shell treats a given character as whitespace like this: + // echo $(echo -e a '\u0009' b) + // If you get "a b" it means the tab character (Unicode 0009) is being collapsed away. + // If you get "a b" then the invisible character is being padded like a normal letter. + return c === ' ' || c === '\t'; +} diff --git a/libraries/terminal/src/AnsiEscape.ts b/libraries/terminal/src/AnsiEscape.ts index 7c1df4e036d..1ffa24aa082 100644 --- a/libraries/terminal/src/AnsiEscape.ts +++ b/libraries/terminal/src/AnsiEscape.ts @@ -14,6 +14,17 @@ export interface IAnsiEscapeConvertForTestsOptions { encodeNewlines?: boolean; } +// For now, we only care about the Control Sequence Introducer (CSI) commands which always start with "[". +// eslint-disable-next-line no-control-regex +const _csiRegExp: RegExp = /\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])/gu; + +// Text coloring is performed using Select Graphic Rendition (SGR) codes, which come after the +// CSI introducer "ESC [". The SGR sequence is a number followed by "m". +const _sgrRegExp: RegExp = /([0-9]+)m/u; + +const _backslashNRegExp: RegExp = /\n/g; +const _backslashRRegExp: RegExp = /\r/g; + /** * Operations for working with text strings that contain * {@link https://en.wikipedia.org/wiki/ANSI_escape_code | ANSI escape codes}. @@ -21,17 +32,6 @@ export interface IAnsiEscapeConvertForTestsOptions { * @public */ export class AnsiEscape { - // For now, we only care about the Control Sequence Introducer (CSI) commands which always start with "[". - // eslint-disable-next-line no-control-regex - private static readonly _csiRegExp: RegExp = /\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])/gu; - - // Text coloring is performed using Select Graphic Rendition (SGR) codes, which come after the - // CSI introducer "ESC [". The SGR sequence is a number followed by "m". - private static readonly _sgrRegExp: RegExp = /([0-9]+)m/u; - - private static readonly _backslashNRegExp: RegExp = /\n/g; - private static readonly _backslashRRegExp: RegExp = /\r/g; - public static getEscapeSequenceForAnsiCode(code: number): string { return `\u001b[${code}m`; } @@ -41,7 +41,7 @@ export class AnsiEscape { * colorized console output to a log file. */ public static removeCodes(text: string): string { - return text.replace(AnsiEscape._csiRegExp, ''); + return text.replace(_csiRegExp, ''); } /** @@ -53,12 +53,12 @@ export class AnsiEscape { options = {}; } - let result: string = text.replace(AnsiEscape._csiRegExp, (capture: string, csiCode: string) => { + let result: string = text.replace(_csiRegExp, (capture: string, csiCode: string) => { // If it is an SGR code, then try to show a friendly token - const match: RegExpMatchArray | null = csiCode.match(AnsiEscape._sgrRegExp); + const match: RegExpMatchArray | null = csiCode.match(_sgrRegExp); if (match) { const sgrParameter: number = parseInt(match[1], 10); - const sgrParameterName: string | undefined = AnsiEscape._tryGetSgrFriendlyName(sgrParameter); + const sgrParameterName: string | undefined = _tryGetSgrFriendlyName(sgrParameter); if (sgrParameterName) { // Example: "[black-bg]" return `[${sgrParameterName}]`; @@ -71,84 +71,82 @@ export class AnsiEscape { }); if (options.encodeNewlines) { - result = result - .replace(AnsiEscape._backslashNRegExp, '[n]') - .replace(AnsiEscape._backslashRRegExp, `[r]`); + result = result.replace(_backslashNRegExp, '[n]').replace(_backslashRRegExp, `[r]`); } return result; } +} - // Returns a human-readable token representing an SGR parameter, or undefined for parameter that is not well-known. - // The SGR parameter numbers are documented in this table: - // https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters - private static _tryGetSgrFriendlyName(sgiParameter: number): string | undefined { - switch (sgiParameter) { - case SgrParameterAttribute.BlackForeground: - return 'black'; - case SgrParameterAttribute.RedForeground: - return 'red'; - case SgrParameterAttribute.GreenForeground: - return 'green'; - case SgrParameterAttribute.YellowForeground: - return 'yellow'; - case SgrParameterAttribute.BlueForeground: - return 'blue'; - case SgrParameterAttribute.MagentaForeground: - return 'magenta'; - case SgrParameterAttribute.CyanForeground: - return 'cyan'; - case SgrParameterAttribute.WhiteForeground: - return 'white'; - case SgrParameterAttribute.GrayForeground: - return 'gray'; - case SgrParameterAttribute.DefaultForeground: - return 'default'; +// Returns a human-readable token representing an SGR parameter, or undefined for parameter that is not well-known. +// The SGR parameter numbers are documented in this table: +// https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +function _tryGetSgrFriendlyName(sgiParameter: number): string | undefined { + switch (sgiParameter) { + case SgrParameterAttribute.BlackForeground: + return 'black'; + case SgrParameterAttribute.RedForeground: + return 'red'; + case SgrParameterAttribute.GreenForeground: + return 'green'; + case SgrParameterAttribute.YellowForeground: + return 'yellow'; + case SgrParameterAttribute.BlueForeground: + return 'blue'; + case SgrParameterAttribute.MagentaForeground: + return 'magenta'; + case SgrParameterAttribute.CyanForeground: + return 'cyan'; + case SgrParameterAttribute.WhiteForeground: + return 'white'; + case SgrParameterAttribute.GrayForeground: + return 'gray'; + case SgrParameterAttribute.DefaultForeground: + return 'default'; - case SgrParameterAttribute.BlackBackground: - return 'black-bg'; - case SgrParameterAttribute.RedBackground: - return 'red-bg'; - case SgrParameterAttribute.GreenBackground: - return 'green-bg'; - case SgrParameterAttribute.YellowBackground: - return 'yellow-bg'; - case SgrParameterAttribute.BlueBackground: - return 'blue-bg'; - case SgrParameterAttribute.MagentaBackground: - return 'magenta-bg'; - case SgrParameterAttribute.CyanBackground: - return 'cyan-bg'; - case SgrParameterAttribute.WhiteBackground: - return 'white-bg'; - case SgrParameterAttribute.GrayBackground: - return 'gray-bg'; - case SgrParameterAttribute.DefaultBackground: - return 'default-bg'; + case SgrParameterAttribute.BlackBackground: + return 'black-bg'; + case SgrParameterAttribute.RedBackground: + return 'red-bg'; + case SgrParameterAttribute.GreenBackground: + return 'green-bg'; + case SgrParameterAttribute.YellowBackground: + return 'yellow-bg'; + case SgrParameterAttribute.BlueBackground: + return 'blue-bg'; + case SgrParameterAttribute.MagentaBackground: + return 'magenta-bg'; + case SgrParameterAttribute.CyanBackground: + return 'cyan-bg'; + case SgrParameterAttribute.WhiteBackground: + return 'white-bg'; + case SgrParameterAttribute.GrayBackground: + return 'gray-bg'; + case SgrParameterAttribute.DefaultBackground: + return 'default-bg'; - case SgrParameterAttribute.Bold: - return 'bold'; - case SgrParameterAttribute.Dim: - return 'dim'; - case SgrParameterAttribute.NormalColorOrIntensity: - return 'normal'; - case SgrParameterAttribute.Underline: - return 'underline'; - case SgrParameterAttribute.UnderlineOff: - return 'underline-off'; - case SgrParameterAttribute.Blink: - return 'blink'; - case SgrParameterAttribute.BlinkOff: - return 'blink-off'; - case SgrParameterAttribute.InvertColor: - return 'invert'; - case SgrParameterAttribute.InvertColorOff: - return 'invert-off'; - case SgrParameterAttribute.Hidden: - return 'hidden'; - case SgrParameterAttribute.HiddenOff: - return 'hidden-off'; - default: - return undefined; - } + case SgrParameterAttribute.Bold: + return 'bold'; + case SgrParameterAttribute.Dim: + return 'dim'; + case SgrParameterAttribute.NormalColorOrIntensity: + return 'normal'; + case SgrParameterAttribute.Underline: + return 'underline'; + case SgrParameterAttribute.UnderlineOff: + return 'underline-off'; + case SgrParameterAttribute.Blink: + return 'blink'; + case SgrParameterAttribute.BlinkOff: + return 'blink-off'; + case SgrParameterAttribute.InvertColor: + return 'invert'; + case SgrParameterAttribute.InvertColorOff: + return 'invert-off'; + case SgrParameterAttribute.Hidden: + return 'hidden'; + case SgrParameterAttribute.HiddenOff: + return 'hidden-off'; + default: + return undefined; } } diff --git a/libraries/terminal/src/Colorize.ts b/libraries/terminal/src/Colorize.ts index 94e1f42e3be..aad6e53c5da 100644 --- a/libraries/terminal/src/Colorize.ts +++ b/libraries/terminal/src/Colorize.ts @@ -74,7 +74,7 @@ const RAINBOW_SEQUENCE: SgrParameterAttribute[] = [ */ export class Colorize { public static black(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.BlackForeground, SgrParameterAttribute.DefaultForeground, text @@ -82,7 +82,7 @@ export class Colorize { } public static red(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.RedForeground, SgrParameterAttribute.DefaultForeground, text @@ -90,7 +90,7 @@ export class Colorize { } public static green(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.GreenForeground, SgrParameterAttribute.DefaultForeground, text @@ -98,7 +98,7 @@ export class Colorize { } public static yellow(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.YellowForeground, SgrParameterAttribute.DefaultForeground, text @@ -106,7 +106,7 @@ export class Colorize { } public static blue(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.BlueForeground, SgrParameterAttribute.DefaultForeground, text @@ -114,7 +114,7 @@ export class Colorize { } public static magenta(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.MagentaForeground, SgrParameterAttribute.DefaultForeground, text @@ -122,7 +122,7 @@ export class Colorize { } public static cyan(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.CyanForeground, SgrParameterAttribute.DefaultForeground, text @@ -130,7 +130,7 @@ export class Colorize { } public static white(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.WhiteForeground, SgrParameterAttribute.DefaultForeground, text @@ -138,7 +138,7 @@ export class Colorize { } public static gray(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.GrayForeground, SgrParameterAttribute.DefaultForeground, text @@ -146,7 +146,7 @@ export class Colorize { } public static blackBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.BlackBackground, SgrParameterAttribute.DefaultBackground, text @@ -154,7 +154,7 @@ export class Colorize { } public static redBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.RedBackground, SgrParameterAttribute.DefaultBackground, text @@ -162,7 +162,7 @@ export class Colorize { } public static greenBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.GreenBackground, SgrParameterAttribute.DefaultBackground, text @@ -170,7 +170,7 @@ export class Colorize { } public static yellowBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.YellowBackground, SgrParameterAttribute.DefaultBackground, text @@ -178,7 +178,7 @@ export class Colorize { } public static blueBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.BlueBackground, SgrParameterAttribute.DefaultBackground, text @@ -186,7 +186,7 @@ export class Colorize { } public static magentaBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.MagentaBackground, SgrParameterAttribute.DefaultBackground, text @@ -194,7 +194,7 @@ export class Colorize { } public static cyanBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.CyanBackground, SgrParameterAttribute.DefaultBackground, text @@ -202,7 +202,7 @@ export class Colorize { } public static whiteBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.WhiteBackground, SgrParameterAttribute.DefaultBackground, text @@ -210,7 +210,7 @@ export class Colorize { } public static grayBackground(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.GrayBackground, SgrParameterAttribute.DefaultBackground, text @@ -218,7 +218,7 @@ export class Colorize { } public static bold(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.Bold, SgrParameterAttribute.NormalColorOrIntensity, text @@ -226,7 +226,7 @@ export class Colorize { } public static dim(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.Dim, SgrParameterAttribute.NormalColorOrIntensity, text @@ -234,7 +234,7 @@ export class Colorize { } public static underline(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.Underline, SgrParameterAttribute.UnderlineOff, text @@ -242,15 +242,11 @@ export class Colorize { } public static blink(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( - SgrParameterAttribute.Blink, - SgrParameterAttribute.BlinkOff, - text - ); + return _wrapTextInAnsiEscapeCodes(SgrParameterAttribute.Blink, SgrParameterAttribute.BlinkOff, text); } public static invertColor(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( + return _wrapTextInAnsiEscapeCodes( SgrParameterAttribute.InvertColor, SgrParameterAttribute.InvertColorOff, text @@ -258,32 +254,28 @@ export class Colorize { } public static hidden(text: string): string { - return Colorize._wrapTextInAnsiEscapeCodes( - SgrParameterAttribute.Hidden, - SgrParameterAttribute.HiddenOff, - text - ); + return _wrapTextInAnsiEscapeCodes(SgrParameterAttribute.Hidden, SgrParameterAttribute.HiddenOff, text); } public static rainbow(text: string): string { - return Colorize._applyColorSequence(text, RAINBOW_SEQUENCE); + return _applyColorSequence(text, RAINBOW_SEQUENCE); } +} - private static _applyColorSequence(text: string, sequence: SgrParameterAttribute[]): string { - let result: string = ''; - const sequenceLength: number = sequence.length; - for (let i: number = 0; i < text.length; i++) { - result += AnsiEscape.getEscapeSequenceForAnsiCode(sequence[i % sequenceLength]) + text[i]; - } - - return result + AnsiEscape.getEscapeSequenceForAnsiCode(SgrParameterAttribute.DefaultForeground); +function _applyColorSequence(text: string, sequence: SgrParameterAttribute[]): string { + let result: string = ''; + const sequenceLength: number = sequence.length; + for (let i: number = 0; i < text.length; i++) { + result += AnsiEscape.getEscapeSequenceForAnsiCode(sequence[i % sequenceLength]) + text[i]; } - private static _wrapTextInAnsiEscapeCodes(startCode: number, endCode: number, text: string): string { - return ( - AnsiEscape.getEscapeSequenceForAnsiCode(startCode) + - text + - AnsiEscape.getEscapeSequenceForAnsiCode(endCode) - ); - } + return result + AnsiEscape.getEscapeSequenceForAnsiCode(SgrParameterAttribute.DefaultForeground); +} + +function _wrapTextInAnsiEscapeCodes(startCode: number, endCode: number, text: string): string { + return ( + AnsiEscape.getEscapeSequenceForAnsiCode(startCode) + + text + + AnsiEscape.getEscapeSequenceForAnsiCode(endCode) + ); } diff --git a/libraries/tree-pattern/src/TreePattern.ts b/libraries/tree-pattern/src/TreePattern.ts index 92d276196a5..4876f30e28d 100644 --- a/libraries/tree-pattern/src/TreePattern.ts +++ b/libraries/tree-pattern/src/TreePattern.ts @@ -128,109 +128,109 @@ export class TreePattern { * @returns `true` if `root` matches the pattern, or `false` otherwise */ public match(root: TreeNode, captures: ITreePatternCaptureSet = {}): boolean { - return TreePattern._matchTreeRecursive(root, this._pattern, captures, 'root'); + return _matchTreeRecursive(root, this._pattern, captures, 'root'); } +} - private static _matchTreeRecursive( - root: TreeNode, - pattern: TreeNode, - captures: ITreePatternCaptureSet, - path: string - ): boolean { - if (pattern === undefined) { - throw new Error('pattern has an undefined value at ' + path); - } +function _matchTreeRecursive( + root: TreeNode, + pattern: TreeNode, + captures: ITreePatternCaptureSet, + path: string +): boolean { + if (pattern === undefined) { + throw new Error('pattern has an undefined value at ' + path); + } - // Avoid "Element implicitly has an 'any' type" (TS7053) - const castedCaptures: Record = captures; + // Avoid "Element implicitly has an 'any' type" (TS7053) + const castedCaptures: Record = captures; - if (pattern instanceof TreePatternArg) { - if (pattern.subtree !== undefined) { - if (!TreePattern._matchTreeRecursive(root, pattern.subtree, captures, path)) { - return false; - } + if (pattern instanceof TreePatternArg) { + if (pattern.subtree !== undefined) { + if (!_matchTreeRecursive(root, pattern.subtree, captures, path)) { + return false; } - - castedCaptures[pattern.keyName] = root; - return true; } - if (pattern instanceof TreePatternAlternatives) { - // Try each possible alternative until we find one that matches - for (const possibleSubtree of pattern.possibleSubtrees) { - // We shouldn't update "captures" unless the match is fully successful. - // So make a temporary copy of it. - const tempCaptures: Record = { ...captures }; - if (TreePattern._matchTreeRecursive(root, possibleSubtree, tempCaptures, path)) { - // The match was successful, so assign the tempCaptures results back into the - // original "captures" object. - for (const key of Object.getOwnPropertyNames(tempCaptures)) { - castedCaptures[key] = tempCaptures[key]; - } - return true; + castedCaptures[pattern.keyName] = root; + return true; + } + + if (pattern instanceof TreePatternAlternatives) { + // Try each possible alternative until we find one that matches + for (const possibleSubtree of pattern.possibleSubtrees) { + // We shouldn't update "captures" unless the match is fully successful. + // So make a temporary copy of it. + const tempCaptures: Record = { ...captures }; + if (_matchTreeRecursive(root, possibleSubtree, tempCaptures, path)) { + // The match was successful, so assign the tempCaptures results back into the + // original "captures" object. + for (const key of Object.getOwnPropertyNames(tempCaptures)) { + castedCaptures[key] = tempCaptures[key]; } + return true; } + } + + // None of the alternatives matched + return false; + } - // None of the alternatives matched + if (Array.isArray(pattern)) { + if (!Array.isArray(root)) { + captures.failPath = path; return false; } - if (Array.isArray(pattern)) { - if (!Array.isArray(root)) { - captures.failPath = path; - return false; - } + if (root.length !== pattern.length) { + captures.failPath = path; + return false; + } + + for (let i: number = 0; i < pattern.length; ++i) { + const subPath: string = path + '[' + i + ']'; - if (root.length !== pattern.length) { - captures.failPath = path; + const rootElement: TreeNode = root[i]; + const patternElement: TreeNode = pattern[i]; + if (!_matchTreeRecursive(rootElement, patternElement, captures, subPath)) { return false; } + } - for (let i: number = 0; i < pattern.length; ++i) { - const subPath: string = path + '[' + i + ']'; - - const rootElement: TreeNode = root[i]; - const patternElement: TreeNode = pattern[i]; - if (!TreePattern._matchTreeRecursive(rootElement, patternElement, captures, subPath)) { - return false; - } - } + return true; + } - return true; + // In JavaScript, typeof null === 'object' + if (typeof pattern === 'object' && pattern !== null) { + if (typeof root !== 'object' || root === null) { + captures.failPath = path; + return false; } - // In JavaScript, typeof null === 'object' - if (typeof pattern === 'object' && pattern !== null) { - if (typeof root !== 'object' || root === null) { - captures.failPath = path; - return false; + for (const keyName of Object.getOwnPropertyNames(pattern)) { + let subPath: string; + if (/^[a-z_][a-z0-9_]*$/i.test(keyName)) { + subPath = path + '.' + keyName; + } else { + subPath = path + '[' + JSON.stringify(keyName) + ']'; } - for (const keyName of Object.getOwnPropertyNames(pattern)) { - let subPath: string; - if (/^[a-z_][a-z0-9_]*$/i.test(keyName)) { - subPath = path + '.' + keyName; - } else { - subPath = path + '[' + JSON.stringify(keyName) + ']'; - } - - if (!Object.hasOwnProperty.call(root, keyName)) { - captures.failPath = subPath; - return false; - } - if (!TreePattern._matchTreeRecursive(root[keyName], pattern[keyName], captures, subPath)) { - return false; - } + if (!Object.hasOwnProperty.call(root, keyName)) { + captures.failPath = subPath; + return false; + } + if (!_matchTreeRecursive(root[keyName], pattern[keyName], captures, subPath)) { + return false; } - - return true; - } - - if (root !== pattern) { - captures.failPath = path; - return false; } return true; } + + if (root !== pattern) { + captures.failPath = path; + return false; + } + + return true; } diff --git a/libraries/ts-command-line/src/parameters/BaseClasses.ts b/libraries/ts-command-line/src/parameters/BaseClasses.ts index 48b4cecb262..0f5a0a1e0de 100644 --- a/libraries/ts-command-line/src/parameters/BaseClasses.ts +++ b/libraries/ts-command-line/src/parameters/BaseClasses.ts @@ -14,6 +14,9 @@ import type { CommandLineIntegerParameter } from './CommandLineIntegerParameter' import type { CommandLineStringListParameter } from './CommandLineStringListParameter'; import type { CommandLineStringParameter } from './CommandLineStringParameter'; +// Matches the first character that *isn't* part of a valid upper-case argument name such as "URL_2" +const _invalidArgumentNameRegExp: RegExp = /[^A-Z_0-9]/; + /** * Identifies the kind of a CommandLineParameter. * @public @@ -276,9 +279,6 @@ export abstract class CommandLineParameterBase { * @public */ export abstract class CommandLineParameterWithArgument extends CommandLineParameterBase { - // Matches the first character that *isn't* part of a valid upper-case argument name such as "URL_2" - private static _invalidArgumentNameRegExp: RegExp = /[^A-Z_0-9]/; - /** {@inheritDoc IBaseCommandLineDefinitionWithArgument.argumentName} */ public readonly argumentName: string; @@ -301,9 +301,7 @@ export abstract class CommandLineParameterWithArgument extends CommandLineParame `Invalid name: "${definition.argumentName}". The argument name must be all upper case.` ); } - const match: RegExpMatchArray | null = definition.argumentName.match( - CommandLineParameterWithArgument._invalidArgumentNameRegExp - ); + const match: RegExpMatchArray | null = definition.argumentName.match(_invalidArgumentNameRegExp); if (match) { throw new Error( `The argument name "${definition.argumentName}" contains an invalid character "${match[0]}".` + diff --git a/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts b/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts index 9ff64b48a25..e7fd003f678 100644 --- a/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts +++ b/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts @@ -89,6 +89,8 @@ const LONG_NAME_GROUP_NAME: string = 'longName'; const POSSIBLY_SCOPED_LONG_NAME_REGEXP: RegExp = /^--((?[a-z0-9]+(-[a-z0-9]+)*):)?(?[a-z0-9]+((-[a-z0-9]+)+)?)$/; +let _keyCounter: number = 0; + interface IExtendedArgumentGroup extends argparse.ArgumentGroup { // The types are incorrect - this function returns the constructed argument // object, which looks like the argument options type. @@ -107,8 +109,6 @@ interface IExtendedArgumentParser extends argparse.ArgumentParser { * @public */ export abstract class CommandLineParameterProvider { - private static _keyCounter: number = 0; - /** @internal */ public readonly _ambiguousParameterParserKeysByName: Map; /** @internal */ @@ -971,7 +971,7 @@ export abstract class CommandLineParameterProvider { } private _generateKey(): string { - return 'key_' + (CommandLineParameterProvider._keyCounter++).toString(); + return 'key_' + (_keyCounter++).toString(); } private _getParameter( diff --git a/repo-scripts/repo-toolbox/src/cli/actions/ReadmeAction.ts b/repo-scripts/repo-toolbox/src/cli/actions/ReadmeAction.ts index 6fa73e971a2..13cf6bdf913 100644 --- a/repo-scripts/repo-toolbox/src/cli/actions/ReadmeAction.ts +++ b/repo-scripts/repo-toolbox/src/cli/actions/ReadmeAction.ts @@ -34,10 +34,6 @@ export class ReadmeAction extends CommandLineAction { }); } - private static _isPublished(project: RushConfigurationProject): boolean { - return project.shouldPublish || !!project.versionPolicyName; - } - protected override async onExecuteAsync(): Promise { const rushConfiguration: RushConfiguration = RushConfiguration.loadFromDefaultLocation(); @@ -77,7 +73,7 @@ export class ReadmeAction extends CommandLineAction { builder.append('| Folder | Version | Changelog | Package |\n'); builder.append('| ------ | ------- | --------- | ------- |\n'); for (const project of orderedProjects) { - if (!ReadmeAction._isPublished(project)) { + if (!_isPublished(project)) { continue; } @@ -131,7 +127,7 @@ export class ReadmeAction extends CommandLineAction { builder.append('| Folder | Description |\n'); builder.append('| ------ | -----------|\n'); for (const project of orderedProjects) { - if (ReadmeAction._isPublished(project)) { + if (_isPublished(project)) { continue; } @@ -183,3 +179,7 @@ export class ReadmeAction extends CommandLineAction { } } } + +function _isPublished(project: RushConfigurationProject): boolean { + return project.shouldPublish || !!project.versionPolicyName; +} diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js index 19120a33a8b..4b78d53b57d 100644 --- a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js @@ -180,6 +180,14 @@ module.exports = { // Forbid only bare `export * from '...'` selector: 'ExportAllDeclaration[exported=null]', message: "Use explicit named exports instead of `export * from '...'`." + }, + { + selector: 'MethodDefinition[accessibility="private"][static=true]', + message: 'Use a module-scoped function instead of a `private static` method.' + }, + { + selector: 'PropertyDefinition[accessibility="private"][static=true]', + message: 'Use a module-scoped variable instead of a `private static` property.' } ] } diff --git a/vscode-extensions/rush-vscode-extension/src/logic/RushCommandWebViewPanel.ts b/vscode-extensions/rush-vscode-extension/src/logic/RushCommandWebViewPanel.ts index 5a66d982b5b..dd961cec02e 100644 --- a/vscode-extensions/rush-vscode-extension/src/logic/RushCommandWebViewPanel.ts +++ b/vscode-extensions/rush-vscode-extension/src/logic/RushCommandWebViewPanel.ts @@ -7,8 +7,9 @@ import { FileSystem } from '@rushstack/node-core-library'; import type { IFromExtensionMessage, IRootState } from '@rushstack/rush-vscode-command-webview'; +let _instance: RushCommandWebViewPanel | undefined; + export class RushCommandWebViewPanel { - private static _instance: RushCommandWebViewPanel | undefined; private _panel: vscode.WebviewView | undefined; private _webViewProvider: vscode.WebviewViewProvider | undefined; private _context: vscode.ExtensionContext; @@ -19,19 +20,19 @@ export class RushCommandWebViewPanel { } public static getInstance(): RushCommandWebViewPanel { - if (!RushCommandWebViewPanel._instance) { + if (!_instance) { throw new Error('Instance has not been initialized!'); } - return RushCommandWebViewPanel._instance; + return _instance; } public static initialize(context: vscode.ExtensionContext): RushCommandWebViewPanel { - if (RushCommandWebViewPanel._instance) { + if (_instance) { throw new Error('Only one instance of rush command web view panel should be created!'); } - RushCommandWebViewPanel._instance = new RushCommandWebViewPanel(context); - return RushCommandWebViewPanel._instance; + _instance = new RushCommandWebViewPanel(context); + return _instance; } public postMessage(message: IFromExtensionMessage): void { diff --git a/vscode-extensions/rush-vscode-extension/src/logic/RushWorkspace.ts b/vscode-extensions/rush-vscode-extension/src/logic/RushWorkspace.ts index bbff82b77f2..eda94a0de6d 100644 --- a/vscode-extensions/rush-vscode-extension/src/logic/RushWorkspace.ts +++ b/vscode-extensions/rush-vscode-extension/src/logic/RushWorkspace.ts @@ -22,17 +22,17 @@ export interface IRushWorkspace { startingFolder: string; } +let _rushWorkspace: RushWorkspace | undefined; + +const _onDidChangeWorkspace: vscode.EventEmitter = new vscode.EventEmitter(); + export class RushWorkspace { private _rushLib: typeof RushLib | undefined; private _startingFolderPath: string; private _rushConfiguration: RushLib.RushConfiguration; private _rushCommandLineParser: RushCommandLine.CommandLineParser | undefined; - private static _rushWorkspace: RushWorkspace | undefined; - private static readonly _onDidChangeWorkspace: vscode.EventEmitter = - new vscode.EventEmitter(); - public static readonly onDidChangeWorkspace: vscode.Event = - RushWorkspace._onDidChangeWorkspace.event; + public static readonly onDidChangeWorkspace: vscode.Event = _onDidChangeWorkspace.event; private constructor({ rushLib, startingFolder }: IRushWorkspace) { this._rushLib = rushLib; @@ -60,15 +60,15 @@ export class RushWorkspace { // terminal.writeWarningLine(`load RushCommandLineParser from rush-sdk failed`); // } - RushWorkspace._rushWorkspace = this; - RushWorkspace._onDidChangeWorkspace.fire(this); + _rushWorkspace = this; + _onDidChangeWorkspace.fire(this); } public static getCurrentInstance(): RushWorkspace { - if (!RushWorkspace._rushWorkspace) { + if (!_rushWorkspace) { throw new Error('RushWorkspace not initialized'); } - return RushWorkspace._rushWorkspace; + return _rushWorkspace; } public static async initializeFromWorkspaceFolderPathsAsync( diff --git a/webpack/loader-load-themed-styles/src/LoadThemedStylesLoader.ts b/webpack/loader-load-themed-styles/src/LoadThemedStylesLoader.ts index a11d8d90f70..feb7c5ee56a 100644 --- a/webpack/loader-load-themed-styles/src/LoadThemedStylesLoader.ts +++ b/webpack/loader-load-themed-styles/src/LoadThemedStylesLoader.ts @@ -12,6 +12,8 @@ import loaderUtils = require('loader-utils'); const loadedThemedStylesPath: string = require.resolve('@microsoft/load-themed-styles'); +let _loadedThemedStylesPath: string = loadedThemedStylesPath; + /** * Options for the loader. * @@ -32,28 +34,26 @@ export interface ILoadThemedStylesLoaderOptions { * @public */ export class LoadThemedStylesLoader { - private static _loadedThemedStylesPath: string = loadedThemedStylesPath; - public constructor() { throw new Error('Constructing "LoadThemedStylesLoader" is not supported.'); } public static set loadedThemedStylesPath(value: string) { - LoadThemedStylesLoader._loadedThemedStylesPath = value; + _loadedThemedStylesPath = value; } /** * Use this property to override the path to the `@microsoft/load-themed-styles` package. */ public static get loadedThemedStylesPath(): string { - return LoadThemedStylesLoader._loadedThemedStylesPath; + return _loadedThemedStylesPath; } /** * Reset the path to the `@microsoft/load-themed-styles package` to the default. */ public static resetLoadedThemedStylesPath(): void { - LoadThemedStylesLoader._loadedThemedStylesPath = loadedThemedStylesPath; + _loadedThemedStylesPath = loadedThemedStylesPath; } public static pitch(this: loader.LoaderContext, remainingRequest: string): string { @@ -66,7 +66,7 @@ export class LoadThemedStylesLoader { return [ `var content = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRequest)});`, - `var loader = require(${JSON.stringify(LoadThemedStylesLoader._loadedThemedStylesPath)});`, + `var loader = require(${JSON.stringify(_loadedThemedStylesPath)});`, '', 'if(typeof content === "string") content = [[module.id, content]];', '', diff --git a/webpack/webpack4-localization-plugin/src/AssetProcessor.ts b/webpack/webpack4-localization-plugin/src/AssetProcessor.ts index fd2780153ed..3553fc6780b 100644 --- a/webpack/webpack4-localization-plugin/src/AssetProcessor.ts +++ b/webpack/webpack4-localization-plugin/src/AssetProcessor.ts @@ -92,12 +92,12 @@ export class AssetProcessor { ): Map { const assetSource: string = options.asset.source(); - const parsedAsset: IParseResult = AssetProcessor._parseStringToReconstructionSequence( + const parsedAsset: IParseResult = _parseStringToReconstructionSequence( options.plugin, assetSource, - this._getJsonpFunction(options.chunk, options.chunkHasLocalizedModules, options.noStringsLocaleName) + _getJsonpFunction(options.chunk, options.chunkHasLocalizedModules, options.noStringsLocaleName) ); - const reconstructedAsset: ILocalizedReconstructionResult = AssetProcessor._reconstructLocalized( + const reconstructedAsset: ILocalizedReconstructionResult = _reconstructLocalized( parsedAsset.reconstructionSeries, options.locales, options.fillMissingTranslationStrings, @@ -105,14 +105,14 @@ export class AssetProcessor { options.asset.size() ); - const parsedAssetName: IParseResult = AssetProcessor._parseStringToReconstructionSequence( + const parsedAssetName: IParseResult = _parseStringToReconstructionSequence( options.plugin, options.assetName, () => { throw new Error('unsupported'); } ); - const reconstructedAssetName: ILocalizedReconstructionResult = AssetProcessor._reconstructLocalized( + const reconstructedAssetName: ILocalizedReconstructionResult = _reconstructLocalized( parsedAssetName.reconstructionSeries, options.locales, options.fillMissingTranslationStrings, @@ -151,25 +151,25 @@ export class AssetProcessor { public static processNonLocalizedAsset(options: IProcessNonLocalizedAssetOptions): IProcessAssetResult { const assetSource: string = options.asset.source(); - const parsedAsset: IParseResult = AssetProcessor._parseStringToReconstructionSequence( + const parsedAsset: IParseResult = _parseStringToReconstructionSequence( options.plugin, assetSource, - this._getJsonpFunction(options.chunk, options.chunkHasLocalizedModules, options.noStringsLocaleName) + _getJsonpFunction(options.chunk, options.chunkHasLocalizedModules, options.noStringsLocaleName) ); - const reconstructedAsset: INonLocalizedReconstructionResult = AssetProcessor._reconstructNonLocalized( + const reconstructedAsset: INonLocalizedReconstructionResult = _reconstructNonLocalized( parsedAsset.reconstructionSeries, options.asset.size(), options.noStringsLocaleName ); - const parsedAssetName: IParseResult = AssetProcessor._parseStringToReconstructionSequence( + const parsedAssetName: IParseResult = _parseStringToReconstructionSequence( options.plugin, options.assetName, () => { throw new Error('unsupported'); } ); - const reconstructedAssetName: INonLocalizedReconstructionResult = AssetProcessor._reconstructNonLocalized( + const reconstructedAssetName: INonLocalizedReconstructionResult = _reconstructNonLocalized( parsedAssetName.reconstructionSeries, options.assetName.length, options.noStringsLocaleName @@ -196,95 +196,19 @@ export class AssetProcessor { asset: newAsset }; } +} - private static _reconstructLocalized( - reconstructionSeries: IReconstructionElement[], - locales: Set, - fillMissingTranslationStrings: boolean, - defaultLocale: string, - initialSize: number - ): ILocalizedReconstructionResult { - const localizedResults: Map = new Map(); - const issues: string[] = []; - - for (const locale of locales) { - const reconstruction: string[] = []; - - let sizeDiff: number = 0; - for (const element of reconstructionSeries) { - switch (element.kind) { - case 'static': { - reconstruction.push((element as IStaticReconstructionElement).staticString); - break; - } - - case 'localized': { - const localizedElement: ILocalizedReconstructionElement = - element as ILocalizedReconstructionElement; - let newValue: string | undefined = localizedElement.values[locale]; - if (!newValue) { - if (fillMissingTranslationStrings) { - newValue = localizedElement.values[defaultLocale]; - } else { - issues.push( - `The string "${localizedElement.stringName}" in "${localizedElement.locFilePath}" is missing in ` + - `the locale ${locale}` - ); - - newValue = '-- MISSING STRING --'; - } - } - - const escapedBackslash: string = localizedElement.escapedBackslash || '\\'; - - // Replace backslashes with the properly escaped backslash - newValue = newValue.replace(/\\/g, escapedBackslash); - - // @todo: look into using JSON.parse(...) to get the escaping characters - const escapingCharacterSequence: string = escapedBackslash.substr(escapedBackslash.length / 2); - - // Ensure the the quotemark, apostrophe, tab, and newline characters are properly escaped - newValue = newValue.replace(/\r/g, `${escapingCharacterSequence}r`); - newValue = newValue.replace(/\n/g, `${escapingCharacterSequence}n`); - newValue = newValue.replace(/\t/g, `${escapingCharacterSequence}t`); - newValue = newValue.replace(/\"/g, `${escapingCharacterSequence}u0022`); - newValue = newValue.replace(/\'/g, `${escapingCharacterSequence}u0027`); - - reconstruction.push(newValue); - sizeDiff += newValue.length - localizedElement.size; - break; - } - - case 'dynamic': { - const dynamicElement: IDynamicReconstructionElement = element as IDynamicReconstructionElement; - const newValue: string = dynamicElement.valueFn(locale, dynamicElement.token); - reconstruction.push(newValue); - sizeDiff += newValue.length - dynamicElement.size; - break; - } - } - } - - const newAssetSource: string = reconstruction.join(''); - localizedResults.set(locale, { - source: newAssetSource, - size: initialSize + sizeDiff - }); - } - - return { - issues, - result: localizedResults - }; - } - - private static _reconstructNonLocalized( - reconstructionSeries: IReconstructionElement[], - initialSize: number, - noStringsLocaleName: string - ): INonLocalizedReconstructionResult { - const issues: string[] = []; - +function _reconstructLocalized( + reconstructionSeries: IReconstructionElement[], + locales: Set, + fillMissingTranslationStrings: boolean, + defaultLocale: string, + initialSize: number +): ILocalizedReconstructionResult { + const localizedResults: Map = new Map(); + const issues: string[] = []; + + for (const locale of locales) { const reconstruction: string[] = []; let sizeDiff: number = 0; @@ -298,12 +222,35 @@ export class AssetProcessor { case 'localized': { const localizedElement: ILocalizedReconstructionElement = element as ILocalizedReconstructionElement; - issues.push( - `The string "${localizedElement.stringName}" in "${localizedElement.locFilePath}" appeared in an asset ` + - 'that is not expected to contain localized resources.' - ); + let newValue: string | undefined = localizedElement.values[locale]; + if (!newValue) { + if (fillMissingTranslationStrings) { + newValue = localizedElement.values[defaultLocale]; + } else { + issues.push( + `The string "${localizedElement.stringName}" in "${localizedElement.locFilePath}" is missing in ` + + `the locale ${locale}` + ); + + newValue = '-- MISSING STRING --'; + } + } + + const escapedBackslash: string = localizedElement.escapedBackslash || '\\'; + + // Replace backslashes with the properly escaped backslash + newValue = newValue.replace(/\\/g, escapedBackslash); + + // @todo: look into using JSON.parse(...) to get the escaping characters + const escapingCharacterSequence: string = escapedBackslash.substr(escapedBackslash.length / 2); + + // Ensure the the quotemark, apostrophe, tab, and newline characters are properly escaped + newValue = newValue.replace(/\r/g, `${escapingCharacterSequence}r`); + newValue = newValue.replace(/\n/g, `${escapingCharacterSequence}n`); + newValue = newValue.replace(/\t/g, `${escapingCharacterSequence}t`); + newValue = newValue.replace(/\"/g, `${escapingCharacterSequence}u0022`); + newValue = newValue.replace(/\'/g, `${escapingCharacterSequence}u0027`); - const newValue: string = '-- NOT EXPECTED TO BE LOCALIZED --'; reconstruction.push(newValue); sizeDiff += newValue.length - localizedElement.size; break; @@ -311,7 +258,7 @@ export class AssetProcessor { case 'dynamic': { const dynamicElement: IDynamicReconstructionElement = element as IDynamicReconstructionElement; - const newValue: string = dynamicElement.valueFn(noStringsLocaleName, dynamicElement.token); + const newValue: string = dynamicElement.valueFn(locale, dynamicElement.token); reconstruction.push(newValue); sizeDiff += newValue.length - dynamicElement.size; break; @@ -320,155 +267,207 @@ export class AssetProcessor { } const newAssetSource: string = reconstruction.join(''); - return { - issues, - result: { - source: newAssetSource, - size: initialSize + sizeDiff + localizedResults.set(locale, { + source: newAssetSource, + size: initialSize + sizeDiff + }); + } + + return { + issues, + result: localizedResults + }; +} + +function _reconstructNonLocalized( + reconstructionSeries: IReconstructionElement[], + initialSize: number, + noStringsLocaleName: string +): INonLocalizedReconstructionResult { + const issues: string[] = []; + + const reconstruction: string[] = []; + + let sizeDiff: number = 0; + for (const element of reconstructionSeries) { + switch (element.kind) { + case 'static': { + reconstruction.push((element as IStaticReconstructionElement).staticString); + break; } - }; + + case 'localized': { + const localizedElement: ILocalizedReconstructionElement = element as ILocalizedReconstructionElement; + issues.push( + `The string "${localizedElement.stringName}" in "${localizedElement.locFilePath}" appeared in an asset ` + + 'that is not expected to contain localized resources.' + ); + + const newValue: string = '-- NOT EXPECTED TO BE LOCALIZED --'; + reconstruction.push(newValue); + sizeDiff += newValue.length - localizedElement.size; + break; + } + + case 'dynamic': { + const dynamicElement: IDynamicReconstructionElement = element as IDynamicReconstructionElement; + const newValue: string = dynamicElement.valueFn(noStringsLocaleName, dynamicElement.token); + reconstruction.push(newValue); + sizeDiff += newValue.length - dynamicElement.size; + break; + } + } } - private static _parseStringToReconstructionSequence( - plugin: LocalizationPlugin, - source: string, - jsonpFunction: (locale: string, chunkIdToken: string | undefined) => string - ): IParseResult { - const issues: string[] = []; - const reconstructionSeries: IReconstructionElement[] = []; - - let lastIndex: number = 0; - let regexResult: RegExpExecArray | null; - while ((regexResult = PLACEHOLDER_REGEX.exec(source))) { - const staticElement: IStaticReconstructionElement = { - kind: 'static', - staticString: source.substring(lastIndex, regexResult.index) - }; - reconstructionSeries.push(staticElement); - - const [placeholder, escapedBackslash, elementLabel, token, placeholderSerialNumber] = regexResult; - - let localizedReconstructionElement: IReconstructionElement; - switch (elementLabel) { - case Constants.STRING_PLACEHOLDER_LABEL: { - const stringData: IStringData | undefined = plugin.getDataForSerialNumber(placeholderSerialNumber); - if (!stringData) { - issues.push(`Missing placeholder ${placeholder}`); - const brokenLocalizedElement: IStaticReconstructionElement = { - kind: 'static', - staticString: placeholder - }; - localizedReconstructionElement = brokenLocalizedElement; - } else { - const localizedElement: ILocalizedReconstructionElement = { - kind: 'localized', - values: stringData.values, - size: placeholder.length, - locFilePath: stringData.locFilePath, - escapedBackslash: escapedBackslash, - stringName: stringData.stringName - }; - localizedReconstructionElement = localizedElement; - } - break; - } + const newAssetSource: string = reconstruction.join(''); + return { + issues, + result: { + source: newAssetSource, + size: initialSize + sizeDiff + } + }; +} - case Constants.LOCALE_NAME_PLACEHOLDER_LABEL: { - const dynamicElement: IDynamicReconstructionElement = { - kind: 'dynamic', - valueFn: (locale: string) => locale, - size: placeholder.length, - escapedBackslash: escapedBackslash +function _parseStringToReconstructionSequence( + plugin: LocalizationPlugin, + source: string, + jsonpFunction: (locale: string, chunkIdToken: string | undefined) => string +): IParseResult { + const issues: string[] = []; + const reconstructionSeries: IReconstructionElement[] = []; + + let lastIndex: number = 0; + let regexResult: RegExpExecArray | null; + while ((regexResult = PLACEHOLDER_REGEX.exec(source))) { + const staticElement: IStaticReconstructionElement = { + kind: 'static', + staticString: source.substring(lastIndex, regexResult.index) + }; + reconstructionSeries.push(staticElement); + + const [placeholder, escapedBackslash, elementLabel, token, placeholderSerialNumber] = regexResult; + + let localizedReconstructionElement: IReconstructionElement; + switch (elementLabel) { + case Constants.STRING_PLACEHOLDER_LABEL: { + const stringData: IStringData | undefined = plugin.getDataForSerialNumber(placeholderSerialNumber); + if (!stringData) { + issues.push(`Missing placeholder ${placeholder}`); + const brokenLocalizedElement: IStaticReconstructionElement = { + kind: 'static', + staticString: placeholder }; - localizedReconstructionElement = dynamicElement; - break; - } - - case Constants.JSONP_PLACEHOLDER_LABEL: { - const dynamicElement: IDynamicReconstructionElement = { - kind: 'dynamic', - valueFn: jsonpFunction, + localizedReconstructionElement = brokenLocalizedElement; + } else { + const localizedElement: ILocalizedReconstructionElement = { + kind: 'localized', + values: stringData.values, size: placeholder.length, + locFilePath: stringData.locFilePath, escapedBackslash: escapedBackslash, - token: token.substring(1, token.length - 1) + stringName: stringData.stringName }; - localizedReconstructionElement = dynamicElement; - break; + localizedReconstructionElement = localizedElement; } + break; + } - default: { - throw new Error(`Unexpected label ${elementLabel}`); - } + case Constants.LOCALE_NAME_PLACEHOLDER_LABEL: { + const dynamicElement: IDynamicReconstructionElement = { + kind: 'dynamic', + valueFn: (locale: string) => locale, + size: placeholder.length, + escapedBackslash: escapedBackslash + }; + localizedReconstructionElement = dynamicElement; + break; } - reconstructionSeries.push(localizedReconstructionElement); - lastIndex = regexResult.index + placeholder.length; - } + case Constants.JSONP_PLACEHOLDER_LABEL: { + const dynamicElement: IDynamicReconstructionElement = { + kind: 'dynamic', + valueFn: jsonpFunction, + size: placeholder.length, + escapedBackslash: escapedBackslash, + token: token.substring(1, token.length - 1) + }; + localizedReconstructionElement = dynamicElement; + break; + } - const lastElement: IStaticReconstructionElement = { - kind: 'static', - staticString: source.substr(lastIndex) - }; - reconstructionSeries.push(lastElement); + default: { + throw new Error(`Unexpected label ${elementLabel}`); + } + } - return { - issues, - reconstructionSeries - }; + reconstructionSeries.push(localizedReconstructionElement); + lastIndex = regexResult.index + placeholder.length; } - private static _getJsonpFunction( - chunk: Webpack.compilation.Chunk, - chunkHasLocalizedModules: (chunk: Webpack.compilation.Chunk) => boolean, - noStringsLocaleName: string - ): (locale: string, chunkIdToken: string | undefined) => string { - const idsWithStrings: Set = new Set(); - const idsWithoutStrings: Set = new Set(); + const lastElement: IStaticReconstructionElement = { + kind: 'static', + staticString: source.substr(lastIndex) + }; + reconstructionSeries.push(lastElement); + + return { + issues, + reconstructionSeries + }; +} - const asyncChunks: Set = chunk.getAllAsyncChunks(); - for (const asyncChunk of asyncChunks) { - const chunkId: number | string | null = asyncChunk.id; +function _getJsonpFunction( + chunk: Webpack.compilation.Chunk, + chunkHasLocalizedModules: (chunk: Webpack.compilation.Chunk) => boolean, + noStringsLocaleName: string +): (locale: string, chunkIdToken: string | undefined) => string { + const idsWithStrings: Set = new Set(); + const idsWithoutStrings: Set = new Set(); - if (chunkId === null || chunkId === undefined) { - throw new Error(`Chunk "${asyncChunk.name}"'s ID is null or undefined.`); - } + const asyncChunks: Set = chunk.getAllAsyncChunks(); + for (const asyncChunk of asyncChunks) { + const chunkId: number | string | null = asyncChunk.id; - if (chunkHasLocalizedModules(asyncChunk)) { - idsWithStrings.add(chunkId); - } else { - idsWithoutStrings.add(chunkId); - } + if (chunkId === null || chunkId === undefined) { + throw new Error(`Chunk "${asyncChunk.name}"'s ID is null or undefined.`); } - if (idsWithStrings.size === 0) { - return () => JSON.stringify(noStringsLocaleName); - } else if (idsWithoutStrings.size === 0) { - return (locale: string) => JSON.stringify(locale); + if (chunkHasLocalizedModules(asyncChunk)) { + idsWithStrings.add(chunkId); } else { - // Generate an array [, ] and an object that is used as an indexer into that - // object that maps chunk IDs to 0s for chunks with localized strings and 1s for chunks without localized - // strings - // - // This can be improved in the future. We can maybe sort the chunks such that the chunks below a certain ID - // number are localized and the those above are not. - const chunkMapping: { [chunkId: string]: number } = {}; - for (const idWithStrings of idsWithStrings) { - chunkMapping[idWithStrings] = 0; - } - - for (const idWithoutStrings of idsWithoutStrings) { - chunkMapping[idWithoutStrings] = 1; - } + idsWithoutStrings.add(chunkId); + } + } - return (locale: string, chunkIdToken: string | undefined) => { - if (!locale) { - throw new Error('Missing locale name.'); - } + if (idsWithStrings.size === 0) { + return () => JSON.stringify(noStringsLocaleName); + } else if (idsWithoutStrings.size === 0) { + return (locale: string) => JSON.stringify(locale); + } else { + // Generate an array [, ] and an object that is used as an indexer into that + // object that maps chunk IDs to 0s for chunks with localized strings and 1s for chunks without localized + // strings + // + // This can be improved in the future. We can maybe sort the chunks such that the chunks below a certain ID + // number are localized and the those above are not. + const chunkMapping: { [chunkId: string]: number } = {}; + for (const idWithStrings of idsWithStrings) { + chunkMapping[idWithStrings] = 0; + } - return `(${JSON.stringify([locale, noStringsLocaleName])})[${JSON.stringify( - chunkMapping - )}[${chunkIdToken}]]`; - }; + for (const idWithoutStrings of idsWithoutStrings) { + chunkMapping[idWithoutStrings] = 1; } + + return (locale: string, chunkIdToken: string | undefined) => { + if (!locale) { + throw new Error('Missing locale name.'); + } + + return `(${JSON.stringify([locale, noStringsLocaleName])})[${JSON.stringify( + chunkMapping + )}[${chunkIdToken}]]`; + }; } } diff --git a/webpack/webpack4-localization-plugin/src/WebpackConfigurationUpdater.ts b/webpack/webpack4-localization-plugin/src/WebpackConfigurationUpdater.ts index a2f11ca07a1..f4603769888 100644 --- a/webpack/webpack4-localization-plugin/src/WebpackConfigurationUpdater.ts +++ b/webpack/webpack4-localization-plugin/src/WebpackConfigurationUpdater.ts @@ -37,11 +37,11 @@ export class WebpackConfigurationUpdater { ignoreString: options.ignoreString }; - WebpackConfigurationUpdater._addLoadersForLocFiles(options, loader, loaderOptions); + _addLoadersForLocFiles(options, loader, loaderOptions); - WebpackConfigurationUpdater._tryUpdateLocaleTokenInPublicPathPlugin(options); + _tryUpdateLocaleTokenInPublicPathPlugin(options); - WebpackConfigurationUpdater._tryUpdateSourceMapFilename(options.configuration); + _tryUpdateSourceMapFilename(options.configuration); } public static amendWebpackConfigurationForInPlaceLocFiles( @@ -54,7 +54,7 @@ export class WebpackConfigurationUpdater { ignoreString: options.ignoreString }; - WebpackConfigurationUpdater._addRulesToConfiguration(options.configuration, [ + _addRulesToConfiguration(options.configuration, [ { test: Constants.RESOURCE_FILE_NAME_REGEXP, use: [ @@ -68,91 +68,88 @@ export class WebpackConfigurationUpdater { } ]); } +} - private static _tryUpdateLocaleTokenInPublicPathPlugin(options: IWebpackConfigurationUpdaterOptions): void { - let setPublicPathPlugin: typeof SetPublicPathPluginPackageType.SetPublicPathPlugin | undefined; - try { - const pluginPackage: typeof SetPublicPathPluginPackageType = require('@rushstack/set-webpack-public-path-plugin'); - setPublicPathPlugin = pluginPackage.SetPublicPathPlugin; - } catch (e) { - // public path plugin isn't present - ignore - } +function _tryUpdateLocaleTokenInPublicPathPlugin(options: IWebpackConfigurationUpdaterOptions): void { + let setPublicPathPlugin: typeof SetPublicPathPluginPackageType.SetPublicPathPlugin | undefined; + try { + const pluginPackage: typeof SetPublicPathPluginPackageType = require('@rushstack/set-webpack-public-path-plugin'); + setPublicPathPlugin = pluginPackage.SetPublicPathPlugin; + } catch (e) { + // public path plugin isn't present - ignore + } - if (setPublicPathPlugin && options.configuration.plugins) { - for (const plugin of options.configuration.plugins) { - if (plugin instanceof setPublicPathPlugin) { - if ( - plugin.options && - plugin.options.scriptName && - plugin.options.scriptName.isTokenized && - plugin.options.scriptName.name - ) { - plugin.options.scriptName.name = plugin.options.scriptName.name.replace( - /\[locale\]/g, - options.localeNameOrPlaceholder - ); - } + if (setPublicPathPlugin && options.configuration.plugins) { + for (const plugin of options.configuration.plugins) { + if (plugin instanceof setPublicPathPlugin) { + if ( + plugin.options && + plugin.options.scriptName && + plugin.options.scriptName.isTokenized && + plugin.options.scriptName.name + ) { + plugin.options.scriptName.name = plugin.options.scriptName.name.replace( + /\[locale\]/g, + options.localeNameOrPlaceholder + ); } } } } +} - private static _addLoadersForLocFiles( - options: IWebpackConfigurationUpdaterOptions, - loader: string, - loaderOptions: IBaseLoaderOptions - ): void { - const { globsToIgnore, configuration } = options; - const rules: Webpack.RuleSetCondition = - globsToIgnore && globsToIgnore.length > 0 - ? { - include: Constants.RESOURCE_FILE_NAME_REGEXP, - exclude: (filePath: string): boolean => - globsToIgnore.some((glob: string): boolean => minimatch(filePath, glob)) - } - : Constants.RESOURCE_FILE_NAME_REGEXP; - WebpackConfigurationUpdater._addRulesToConfiguration(configuration, [ - { - test: rules, - use: [ - { - loader: loader, - options: loaderOptions - } - ], - type: 'json', - sideEffects: false - } - ]); - } - - private static _addRulesToConfiguration( - configuration: Webpack.Configuration, - rules: Webpack.RuleSetRule[] - ): void { - if (!configuration.module) { - configuration.module = { - rules: [] - }; +function _addLoadersForLocFiles( + options: IWebpackConfigurationUpdaterOptions, + loader: string, + loaderOptions: IBaseLoaderOptions +): void { + const { globsToIgnore, configuration } = options; + const rules: Webpack.RuleSetCondition = + globsToIgnore && globsToIgnore.length > 0 + ? { + include: Constants.RESOURCE_FILE_NAME_REGEXP, + exclude: (filePath: string): boolean => + globsToIgnore.some((glob: string): boolean => minimatch(filePath, glob)) + } + : Constants.RESOURCE_FILE_NAME_REGEXP; + _addRulesToConfiguration(configuration, [ + { + test: rules, + use: [ + { + loader: loader, + options: loaderOptions + } + ], + type: 'json', + sideEffects: false } + ]); +} - if (!configuration.module.rules) { - configuration.module.rules = []; - } +function _addRulesToConfiguration(configuration: Webpack.Configuration, rules: Webpack.RuleSetRule[]): void { + if (!configuration.module) { + configuration.module = { + rules: [] + }; + } - configuration.module.rules.push(...rules); + if (!configuration.module.rules) { + configuration.module.rules = []; } - private static _tryUpdateSourceMapFilename(configuration: Webpack.Configuration): void { - if (!configuration.output) { - configuration.output = {}; // This should never happen - } + configuration.module.rules.push(...rules); +} - if (configuration.output.sourceMapFilename !== undefined) { - configuration.output.sourceMapFilename = configuration.output.sourceMapFilename.replace( - FILE_TOKEN_REGEX, - Constants.NO_LOCALE_SOURCE_MAP_FILENAME_TOKEN - ); - } +function _tryUpdateSourceMapFilename(configuration: Webpack.Configuration): void { + if (!configuration.output) { + configuration.output = {}; // This should never happen + } + + if (configuration.output.sourceMapFilename !== undefined) { + configuration.output.sourceMapFilename = configuration.output.sourceMapFilename.replace( + FILE_TOKEN_REGEX, + Constants.NO_LOCALE_SOURCE_MAP_FILENAME_TOKEN + ); } } From b5fc54bc662273e465709c9c4e476cebb4e32cc4 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Jul 2026 19:13:33 -0700 Subject: [PATCH 2/4] Fix OperationBuildCache test broken by private static refactor Moving `_tarUtilityPromise` from a `private static` field to a module-scoped variable silently invalidated the test's `Reflect.set(OperationBuildCache, '_tarUtilityPromise', ...)` mock injection, causing the restore test to run the real tar/untar path (EACCES on mkdir '/repo'). Add an exported `@internal` test seam `_setTarUtilityPromiseForTesting` and use it from the test instead of poking the class internals. --- .../src/logic/buildCache/OperationBuildCache.ts | 10 ++++++++++ .../logic/buildCache/test/OperationBuildCache.test.ts | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts b/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts index b978869647b..b7f98903827 100644 --- a/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts +++ b/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts @@ -115,6 +115,16 @@ function _getCacheId(options: IProjectBuildCacheOptions): string | undefined { }); } +/** + * Overrides the cached `TarExecutable` promise. Exposed for unit testing only. + * @internal + */ +export function _setTarUtilityPromiseForTesting( + promise: Promise | undefined +): void { + _tarUtilityPromise = promise; +} + /** * @internal */ diff --git a/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts b/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts index 2120295dcfc..e25261451a8 100644 --- a/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts +++ b/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts @@ -8,8 +8,9 @@ import type { BuildCacheConfiguration } from '../../../api/BuildCacheConfigurati import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; import type { IGenerateCacheEntryIdOptions } from '../CacheEntryId'; import type { FileSystemBuildCacheProvider } from '../FileSystemBuildCacheProvider'; +import type { TarExecutable } from '../../../utilities/TarExecutable'; -import { OperationBuildCache } from '../OperationBuildCache'; +import { OperationBuildCache, _setTarUtilityPromiseForTesting } from '../OperationBuildCache'; interface ITestOptions { enabled: boolean; @@ -90,7 +91,7 @@ describe(OperationBuildCache.name, () => { }); afterEach(() => { - Reflect.set(OperationBuildCache, '_tarUtilityPromise', undefined); + _setTarUtilityPromiseForTesting(undefined); jest.restoreAllMocks(); }); @@ -144,7 +145,7 @@ describe(OperationBuildCache.name, () => { const deleteFileAsyncSpy: jest.SpyInstance = jest .spyOn(FileSystem, 'deleteFileAsync') .mockResolvedValue(); - Reflect.set(OperationBuildCache, '_tarUtilityPromise', Promise.resolve({ tryUntarAsync })); + _setTarUtilityPromiseForTesting(Promise.resolve({ tryUntarAsync } as unknown as TarExecutable)); const result: boolean = await subject.tryRestoreFromCacheAsync(terminal); From 604530e0e82d518674fbd188f8eb8bbb5060b668 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Jul 2026 19:14:57 -0700 Subject: [PATCH 3/4] Rush change. --- .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ .../forbid-private-static_2026-07-19-02-14-57.json | 11 +++++++++++ 19 files changed, 209 insertions(+) create mode 100644 common/changes/@microsoft/api-documenter/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@microsoft/api-extractor-model/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@microsoft/api-extractor/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@microsoft/loader-load-themed-styles/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@microsoft/rush/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/eslint-plugin-packlets/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/heft-jest-plugin/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/heft/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/lookup-by-path/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/mcp-server/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/node-core-library/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/package-extractor/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/playwright-browser-tunnel/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/rig-package/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/rundown/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/terminal/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/tree-pattern/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/ts-command-line/forbid-private-static_2026-07-19-02-14-57.json create mode 100644 common/changes/@rushstack/webpack4-localization-plugin/forbid-private-static_2026-07-19-02-14-57.json diff --git a/common/changes/@microsoft/api-documenter/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@microsoft/api-documenter/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..771f7e84c78 --- /dev/null +++ b/common/changes/@microsoft/api-documenter/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@microsoft/api-documenter" + } + ], + "packageName": "@microsoft/api-documenter", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@microsoft/api-extractor-model/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@microsoft/api-extractor-model/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..52f6a7d52bc --- /dev/null +++ b/common/changes/@microsoft/api-extractor-model/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@microsoft/api-extractor-model" + } + ], + "packageName": "@microsoft/api-extractor-model", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@microsoft/api-extractor/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@microsoft/api-extractor/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..f7c3a8a84e4 --- /dev/null +++ b/common/changes/@microsoft/api-extractor/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@microsoft/api-extractor" + } + ], + "packageName": "@microsoft/api-extractor", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@microsoft/loader-load-themed-styles/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@microsoft/loader-load-themed-styles/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..99a0421e842 --- /dev/null +++ b/common/changes/@microsoft/loader-load-themed-styles/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@microsoft/loader-load-themed-styles" + } + ], + "packageName": "@microsoft/loader-load-themed-styles", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@microsoft/rush/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@microsoft/rush/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..efcd84c45fb --- /dev/null +++ b/common/changes/@microsoft/rush/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/eslint-plugin-packlets/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/eslint-plugin-packlets/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..a04cd0021ef --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin-packlets/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/eslint-plugin-packlets" + } + ], + "packageName": "@rushstack/eslint-plugin-packlets", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft-jest-plugin/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/heft-jest-plugin/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..5175001a683 --- /dev/null +++ b/common/changes/@rushstack/heft-jest-plugin/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/heft-jest-plugin" + } + ], + "packageName": "@rushstack/heft-jest-plugin", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/heft/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..ef525830e37 --- /dev/null +++ b/common/changes/@rushstack/heft/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/heft" + } + ], + "packageName": "@rushstack/heft", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/lookup-by-path/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/lookup-by-path/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..fe9906e7906 --- /dev/null +++ b/common/changes/@rushstack/lookup-by-path/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/lookup-by-path" + } + ], + "packageName": "@rushstack/lookup-by-path", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/mcp-server/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/mcp-server/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..df2697e982c --- /dev/null +++ b/common/changes/@rushstack/mcp-server/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/mcp-server" + } + ], + "packageName": "@rushstack/mcp-server", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/node-core-library/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/node-core-library/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..db57b2feb86 --- /dev/null +++ b/common/changes/@rushstack/node-core-library/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/node-core-library" + } + ], + "packageName": "@rushstack/node-core-library", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/package-extractor/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/package-extractor/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..75d96dca0c8 --- /dev/null +++ b/common/changes/@rushstack/package-extractor/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/package-extractor" + } + ], + "packageName": "@rushstack/package-extractor", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/playwright-browser-tunnel/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/playwright-browser-tunnel/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..1d268afc0e2 --- /dev/null +++ b/common/changes/@rushstack/playwright-browser-tunnel/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/playwright-browser-tunnel" + } + ], + "packageName": "@rushstack/playwright-browser-tunnel", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/rig-package/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/rig-package/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..c66505525a1 --- /dev/null +++ b/common/changes/@rushstack/rig-package/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/rig-package" + } + ], + "packageName": "@rushstack/rig-package", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/rundown/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/rundown/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..96f1931da4f --- /dev/null +++ b/common/changes/@rushstack/rundown/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/rundown" + } + ], + "packageName": "@rushstack/rundown", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/terminal/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..13894830365 --- /dev/null +++ b/common/changes/@rushstack/terminal/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/tree-pattern/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/tree-pattern/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..619a10c75e3 --- /dev/null +++ b/common/changes/@rushstack/tree-pattern/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/tree-pattern" + } + ], + "packageName": "@rushstack/tree-pattern", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/ts-command-line/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/ts-command-line/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..1f3658b8dc4 --- /dev/null +++ b/common/changes/@rushstack/ts-command-line/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/ts-command-line" + } + ], + "packageName": "@rushstack/ts-command-line", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/webpack4-localization-plugin/forbid-private-static_2026-07-19-02-14-57.json b/common/changes/@rushstack/webpack4-localization-plugin/forbid-private-static_2026-07-19-02-14-57.json new file mode 100644 index 00000000000..c5d7cdffa66 --- /dev/null +++ b/common/changes/@rushstack/webpack4-localization-plugin/forbid-private-static_2026-07-19-02-14-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/webpack4-localization-plugin" + } + ], + "packageName": "@rushstack/webpack4-localization-plugin", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file From 9d71d474a8ca87c30a9b11ca3a93b1499aa272de Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 20 Jul 2026 10:33:14 -0700 Subject: [PATCH 4/4] fixup! Rush change. --- .../buildCache/test/OperationBuildCache.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts b/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts index e25261451a8..d91554100aa 100644 --- a/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts +++ b/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts @@ -80,6 +80,12 @@ describe(OperationBuildCache.name, () => { describe('direct file cloud cache restore', () => { let fakeLockRelease: jest.Mock; + function mockTarSuccess(): jest.Mock { + const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0); + _setTarUtilityPromiseForTesting(Promise.resolve({ tryUntarAsync } as unknown as TarExecutable)); + return tryUntarAsync; + } + beforeEach(() => { fakeLockRelease = jest.fn(); // By default, simulate an uncontended lock acquisition and no pre-existing cache entry. @@ -138,14 +144,13 @@ describe(OperationBuildCache.name, () => { tryDownloadCacheEntryToFileAsync }); const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); - const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0); jest.spyOn(FileSystem, 'deleteFolderAsync').mockResolvedValue(); const moveAsyncSpy: jest.SpyInstance = jest.spyOn(FileSystem, 'moveAsync').mockResolvedValue(); const deleteFileAsyncSpy: jest.SpyInstance = jest .spyOn(FileSystem, 'deleteFileAsync') .mockResolvedValue(); - _setTarUtilityPromiseForTesting(Promise.resolve({ tryUntarAsync } as unknown as TarExecutable)); + const tryUntarAsync: jest.Mock = mockTarSuccess(); const result: boolean = await subject.tryRestoreFromCacheAsync(terminal); @@ -203,13 +208,12 @@ describe(OperationBuildCache.name, () => { tryDownloadCacheEntryToFileAsync }); const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); - const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0); jest.spyOn(FileSystem, 'deleteFolderAsync').mockResolvedValue(); // Simulate the cache entry having been fully populated (e.g. by another local process) // by the time we acquired the lock. jest.spyOn(FileSystem, 'existsAsync').mockResolvedValue(true); - Reflect.set(OperationBuildCache, '_tarUtilityPromise', Promise.resolve({ tryUntarAsync })); + const tryUntarAsync: jest.Mock = mockTarSuccess(); const result: boolean = await subject.tryRestoreFromCacheAsync(terminal); @@ -233,11 +237,10 @@ describe(OperationBuildCache.name, () => { tryDownloadCacheEntryToFileAsync }); const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); - const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0); jest.spyOn(FileSystem, 'deleteFolderAsync').mockResolvedValue(); jest.spyOn(FileSystem, 'moveAsync').mockResolvedValue(); - Reflect.set(OperationBuildCache, '_tarUtilityPromise', Promise.resolve({ tryUntarAsync })); + mockTarSuccess(); const result: boolean = await subject.tryRestoreFromCacheAsync(terminal);