// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. import * as path from 'node:path'; import * as semver from 'semver'; import * as ts from 'typescript'; import * as resolve from 'resolve'; import type { ApiPackage } from '@microsoft/api-extractor-model'; import { TSDocConfigFile } from '@microsoft/tsdoc-config'; import { FileSystem, NewlineKind, PackageJsonLookup, type IPackageJson, type INodePackageJson, Path } from '@rushstack/node-core-library'; import { ExtractorConfig, type IExtractorConfigApiReport } from './ExtractorConfig'; import { Collector } from '../collector/Collector'; import { DtsRollupGenerator, DtsRollupKind } from '../generators/DtsRollupGenerator'; import { ApiModelGenerator } from '../generators/ApiModelGenerator'; import { ApiReportGenerator } from '../generators/ApiReportGenerator'; import { PackageMetadataManager } from '../analyzer/PackageMetadataManager'; import { ValidationEnhancer } from '../enhancers/ValidationEnhancer'; import { DocCommentEnhancer } from '../enhancers/DocCommentEnhancer'; import { CompilerState } from './CompilerState'; import type { ExtractorMessage } from './ExtractorMessage'; import { MessageRouter } from '../collector/MessageRouter'; import { ConsoleMessageId } from './ConsoleMessageId'; import { SourceMapper } from '../collector/SourceMapper'; /** * Runtime options for Extractor. * * @public */ export interface IExtractorInvokeOptions { /** * An optional TypeScript compiler state. This allows an optimization where multiple invocations of API Extractor * can reuse the same TypeScript compiler analysis. */ compilerState?: CompilerState; /** * Indicates that API Extractor is running as part of a local build, e.g. on developer's * machine. * * @remarks * This disables certain validation that would normally be performed for a ship/production build. For example, * the *.api.md report file is automatically updated in a local build. * * The default value is false. */ localBuild?: boolean; /** * If true, API Extractor will include {@link ExtractorLogLevel.Verbose} messages in its output. */ showVerboseMessages?: boolean; /** * If true, API Extractor will print diagnostic information used for troubleshooting problems. * These messages will be included as {@link ExtractorLogLevel.Verbose} output. * * @remarks * Setting `showDiagnostics=true` forces `showVerboseMessages=true`. */ showDiagnostics?: boolean; /** * Specifies an alternate folder path to be used when loading the TypeScript system typings. * * @remarks * API Extractor uses its own TypeScript compiler engine to analyze your project. If your project * is built with a significantly different TypeScript version, sometimes API Extractor may report compilation * errors due to differences in the system typings (e.g. lib.dom.d.ts). You can use the "--typescriptCompilerFolder" * option to specify the folder path where you installed the TypeScript package, and API Extractor's compiler will * use those system typings instead. */ typescriptCompilerFolder?: string; /** * An optional callback function that will be called for each `ExtractorMessage` before it is displayed by * API Extractor. The callback can customize the message, handle it, or discard it. * * @remarks * If a `messageCallback` is not provided, then by default API Extractor will print the messages to * the STDERR/STDOUT console. */ messageCallback?: (message: ExtractorMessage) => void; /** * If true, then any differences between the actual and expected API reports will be * printed on the console. * * @remarks * The diff is not printed if the expected API report file has not been created yet. */ printApiReportDiff?: boolean; } /** * This object represents the outcome of an invocation of API Extractor. * * @public */ export class ExtractorResult { /** * The TypeScript compiler state that was used. */ public readonly compilerState: CompilerState; /** * The API Extractor configuration that was used. */ public readonly extractorConfig: ExtractorConfig; /** * Whether the invocation of API Extractor was successful. For example, if `succeeded` is false, then the build task * would normally return a nonzero process exit code, indicating that the operation failed. * * @remarks * * Normally the operation "succeeds" if `errorCount` and `warningCount` are both zero. However if * {@link IExtractorInvokeOptions.localBuild} is `true`, then the operation "succeeds" if `errorCount` is zero * (i.e. warnings are ignored). */ public readonly succeeded: boolean; /** * Returns true if the API report was found to have changed. */ public readonly apiReportChanged: boolean; /** * Reports the number of errors encountered during analysis. * * @remarks * This does not count exceptions, where unexpected issues prematurely abort the operation. */ public readonly errorCount: number; /** * Reports the number of warnings encountered during analysis. * * @remarks * This does not count warnings that are emitted in the API report file. */ public readonly warningCount: number; /** @internal */ public constructor(properties: ExtractorResult) { const { compilerState, extractorConfig, succeeded, apiReportChanged, errorCount, warningCount } = properties; this.compilerState = compilerState; this.extractorConfig = extractorConfig; this.succeeded = succeeded; this.apiReportChanged = apiReportChanged; this.errorCount = errorCount; this.warningCount = warningCount; } } /** * The starting point for invoking the API Extractor tool. * @public */ export class Extractor { /** * Returns the version number of the API Extractor NPM package. */ public static get version(): string { return _getPackageJson().version; } /** * Returns the package name of the API Extractor NPM package. */ public static get packageName(): string { return _getPackageJson().name; } /** * Load the api-extractor.json config file from the specified path, and then invoke API Extractor. */ public static loadConfigAndInvoke( configFilePath: string, options?: IExtractorInvokeOptions ): ExtractorResult { const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(configFilePath); return Extractor.invoke(extractorConfig, options); } /** * Invoke API Extractor using an already prepared `ExtractorConfig` object. */ public static invoke(extractorConfig: ExtractorConfig, options?: IExtractorInvokeOptions): ExtractorResult { const { packageFolder, messages, tsdocConfiguration, tsdocConfigFile: { filePath: tsdocConfigFilePath, fileNotFound: tsdocConfigFileNotFound }, apiJsonFilePath, newlineKind, reportTempFolder, reportFolder, apiReportEnabled, reportConfigs, testMode, rollupEnabled, publicTrimmedFilePath, alphaTrimmedFilePath, betaTrimmedFilePath, untrimmedFilePath, tsdocMetadataEnabled, tsdocMetadataFilePath } = extractorConfig; const { localBuild = false, compilerState = CompilerState.create(extractorConfig, options), messageCallback, showVerboseMessages = false, showDiagnostics = false, printApiReportDiff = false } = options ?? {}; const sourceMapper: SourceMapper = new SourceMapper(); const messageRouter: MessageRouter = new MessageRouter({ workingPackageFolder: packageFolder, messageCallback, messagesConfig: messages || {}, showVerboseMessages, showDiagnostics, tsdocConfiguration, sourceMapper }); if (tsdocConfigFilePath && !tsdocConfigFileNotFound) { if (!Path.isEqual(tsdocConfigFilePath, ExtractorConfig._tsdocBaseFilePath)) { messageRouter.logVerbose( ConsoleMessageId.UsingCustomTSDocConfig, `Using custom TSDoc config from ${tsdocConfigFilePath}` ); } } _checkCompilerCompatibility(extractorConfig, messageRouter); if (messageRouter.showDiagnostics) { messageRouter.logDiagnostic(''); messageRouter.logDiagnosticHeader('Final prepared ExtractorConfig'); messageRouter.logDiagnostic(extractorConfig.getDiagnosticDump()); messageRouter.logDiagnosticFooter(); messageRouter.logDiagnosticHeader('Compiler options'); const serializedCompilerOptions: object = MessageRouter.buildJsonDumpObject( (compilerState.program as ts.Program).getCompilerOptions() ); messageRouter.logDiagnostic(JSON.stringify(serializedCompilerOptions, undefined, 2)); messageRouter.logDiagnosticFooter(); messageRouter.logDiagnosticHeader('TSDoc configuration'); // Convert the TSDocConfiguration into a tsdoc.json representation const combinedConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromParser(tsdocConfiguration); const serializedTSDocConfig: object = MessageRouter.buildJsonDumpObject( combinedConfigFile.saveToObject() ); messageRouter.logDiagnostic(JSON.stringify(serializedTSDocConfig, undefined, 2)); messageRouter.logDiagnosticFooter(); } const collector: Collector = new Collector({ program: compilerState.program as ts.Program, messageRouter, extractorConfig, sourceMapper }); collector.analyze(); DocCommentEnhancer.analyze(collector); ValidationEnhancer.analyze(collector); const modelBuilder: ApiModelGenerator = new ApiModelGenerator(collector, extractorConfig); const apiPackage: ApiPackage = modelBuilder.buildApiPackage(); if (messageRouter.showDiagnostics) { messageRouter.logDiagnostic(''); // skip a line after any diagnostic messages } if (modelBuilder.docModelEnabled) { messageRouter.logVerbose(ConsoleMessageId.WritingDocModelFile, `Writing: ${apiJsonFilePath}`); apiPackage.saveToJsonFile(apiJsonFilePath, { toolPackage: Extractor.packageName, toolVersion: Extractor.version, newlineConversion: newlineKind, ensureFolderExists: true, testMode }); } function writeApiReport(reportConfig: IExtractorConfigApiReport): boolean { return _writeApiReport( collector, extractorConfig, messageRouter, reportTempFolder, reportFolder, reportConfig, localBuild, printApiReportDiff ); } let anyReportChanged: boolean = false; if (apiReportEnabled) { for (const reportConfig of reportConfigs) { anyReportChanged = writeApiReport(reportConfig) || anyReportChanged; } } if (rollupEnabled) { _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) { // Write the tsdoc-metadata.json file for this project PackageMetadataManager.writeTsdocMetadataFile(tsdocMetadataFilePath, newlineKind); } // Show all the messages that we collected during analysis messageRouter.handleRemainingNonConsoleMessages(); // Determine success let succeeded: boolean; if (localBuild) { // For a local build, fail if there were errors (but ignore warnings) succeeded = messageRouter.errorCount === 0; } else { // For a production build, fail if there were any errors or warnings succeeded = messageRouter.errorCount + messageRouter.warningCount === 0; } return new ExtractorResult({ compilerState, extractorConfig, succeeded, apiReportChanged: anyReportChanged, errorCount: messageRouter.errorCount, warningCount: messageRouter.warningCount }); } } function _getPackageJson(): IPackageJson { return PackageJsonLookup.loadOwnPackageJson(__dirname); } /** * 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 }); 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. 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; } 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.` ); } } } catch (e) { // The compiler detection heuristic is not expected to work in many configurations } } 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); } }