UNPKG

@microsoft/api-extractor

Version:

Validate, document, and review the exported API for a TypeScript library

352 lines 20.5 kB
"use strict"; // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. Object.defineProperty(exports, "__esModule", { value: true }); const path = require("path"); const ts = require("typescript"); const lodash = require("lodash"); const colors = require("colors"); const node_core_library_1 = require("@microsoft/node-core-library"); const ExtractorContext_1 = require("../ExtractorContext"); const ApiJsonGenerator_1 = require("../generators/ApiJsonGenerator"); const ApiFileGenerator_1 = require("../generators/ApiFileGenerator"); const DtsRollupGenerator_1 = require("../generators/dtsRollup/DtsRollupGenerator"); const MonitoredLogger_1 = require("./MonitoredLogger"); const TypeScriptMessageFormatter_1 = require("../utils/TypeScriptMessageFormatter"); /** * Used to invoke the API Extractor tool. * @public */ class Extractor { constructor(config, options) { let mergedLogger; if (options && options.customLogger) { mergedLogger = lodash.merge(lodash.clone(Extractor._defaultLogger), options.customLogger); } else { mergedLogger = Extractor._defaultLogger; } this._monitoredLogger = new MonitoredLogger_1.MonitoredLogger(mergedLogger); this._actualConfig = Extractor._applyConfigDefaults(config); if (!options) { options = {}; } this._localBuild = options.localBuild || false; switch (this.actualConfig.compiler.configType) { case 'tsconfig': const rootFolder = this.actualConfig.compiler.rootFolder; if (!node_core_library_1.FileSystem.exists(rootFolder)) { throw new Error('The root folder does not exist: ' + rootFolder); } this._absoluteRootFolder = path.normalize(path.resolve(rootFolder)); let tsconfig = this.actualConfig.compiler.overrideTsconfig; if (!tsconfig) { // If it wasn't overridden, then load it from disk tsconfig = node_core_library_1.JsonFile.load(path.join(this._absoluteRootFolder, 'tsconfig.json')); } const commandLine = ts.parseJsonConfigFileContent(tsconfig, ts.sys, this._absoluteRootFolder); if (!commandLine.options.skipLibCheck && options.skipLibCheck) { commandLine.options.skipLibCheck = true; console.log(colors.cyan('API Extractor was invoked with skipLibCheck. This is not recommended and may cause ' + 'incorrect type analysis.')); } this._updateCommandLineForTypescriptPackage(commandLine, options); const normalizedEntryPointFile = path.normalize(path.resolve(this._absoluteRootFolder, this.actualConfig.project.entryPointSourceFile)); // Append the normalizedEntryPointFile and remove any non-declaration files from the list const analysisFilePaths = Extractor.generateFilePathsForAnalysis(commandLine.fileNames.concat(normalizedEntryPointFile)); this._program = ts.createProgram(analysisFilePaths, commandLine.options); if (commandLine.errors.length > 0) { const errorText = TypeScriptMessageFormatter_1.TypeScriptMessageFormatter.format(commandLine.errors[0].messageText); throw new Error(`Error parsing tsconfig.json content: ${errorText}`); } break; case 'runtime': if (!options.compilerProgram) { throw new Error('The compiler.configType=runtime configuration was specified,' + ' but the caller did not provide an options.compilerProgram object'); } this._program = options.compilerProgram; const rootDir = this._program.getCompilerOptions().rootDir; if (!rootDir) { throw new Error('The provided compiler state does not specify a root folder'); } if (!node_core_library_1.FileSystem.exists(rootDir)) { throw new Error('The rootDir does not exist: ' + rootDir); } this._absoluteRootFolder = path.resolve(rootDir); break; default: throw new Error('Unsupported config type'); } } /** * 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. * * For configType=tsconfig this happens automatically, but for configType=runtime it is * the responsibility of the custom tooling. The generateFilePathsForAnalysis() function * is provided to facilitate that. Duplicates are removed so that entry points can be * appended without worrying whether they may already appear in the tsconfig.json file list. */ static generateFilePathsForAnalysis(inputFilePaths) { const analysisFilePaths = []; const seenFiles = new Set(); for (const inputFilePath of inputFilePaths) { const inputFileToUpper = 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 (Extractor._declarationFileExtensionRegExp.test(inputFilePath)) { analysisFilePaths.push(inputFilePath); } } } return analysisFilePaths; } static _applyConfigDefaults(config) { // Use the provided config to override the defaults const normalized = lodash.merge(lodash.cloneDeep(Extractor._defaultConfig), config); return normalized; } /** * Returns the normalized configuration object after defaults have been applied. * * @remarks * This is a read-only object. The caller should NOT modify any member of this object. * It is provided for diagnostic purposes. For example, a build script could write * this object to a JSON file to report the final configuration options used by API Extractor. */ get actualConfig() { return this._actualConfig; } /** * Invokes the API Extractor engine, using the configuration that was passed to the constructor. * @deprecated Use {@link Extractor.processProject} instead. */ analyzeProject(options) { this.processProject(options); } /** * Invokes the API Extractor engine, using the configuration that was passed to the constructor. * @param options - provides additional runtime state that is NOT part of the API Extractor * config file. * @returns true for a successful build, or false if the tool chain should fail the build * * @remarks * * This function returns false to indicate that the build failed, i.e. the command-line tool * would return a nonzero exit code. Normally the build fails if there are any errors or * warnings; however, if options.localBuild=true then warnings are ignored. */ processProject(options) { this._monitoredLogger.resetCounters(); if (!options) { options = {}; } const projectConfig = options.projectConfig ? options.projectConfig : this.actualConfig.project; // This helps strict-null-checks to understand that _applyConfigDefaults() eliminated // any undefined members if (!(this.actualConfig.policies && this.actualConfig.validationRules && this.actualConfig.apiJsonFile && this.actualConfig.apiReviewFile && this.actualConfig.dtsRollup)) { throw new Error('The configuration object wasn\'t normalized properly'); } if (!Extractor._declarationFileExtensionRegExp.test(projectConfig.entryPointSourceFile)) { throw new Error('The entry point is not a declaration file: ' + projectConfig.entryPointSourceFile); } const context = new ExtractorContext_1.ExtractorContext({ program: this._program, entryPointFile: path.resolve(this._absoluteRootFolder, projectConfig.entryPointSourceFile), logger: this._monitoredLogger, policies: this.actualConfig.policies, validationRules: this.actualConfig.validationRules }); for (const externalJsonFileFolder of projectConfig.externalJsonFileFolders || []) { context.loadExternalPackages(path.resolve(this._absoluteRootFolder, externalJsonFileFolder)); } const packageBaseName = path.basename(context.packageName); const apiJsonFileConfig = this.actualConfig.apiJsonFile; if (apiJsonFileConfig.enabled) { const outputFolder = path.resolve(this._absoluteRootFolder, apiJsonFileConfig.outputFolder); const jsonGenerator = new ApiJsonGenerator_1.ApiJsonGenerator(); const apiJsonFilename = path.join(outputFolder, packageBaseName + '.api.json'); this._monitoredLogger.logVerbose('Writing: ' + apiJsonFilename); jsonGenerator.writeJsonFile(apiJsonFilename, context); } if (this.actualConfig.apiReviewFile.enabled) { const generator = new ApiFileGenerator_1.ApiFileGenerator(); const apiReviewFilename = packageBaseName + '.api.ts'; const actualApiReviewPath = path.resolve(this._absoluteRootFolder, this.actualConfig.apiReviewFile.tempFolder, apiReviewFilename); const actualApiReviewShortPath = this._getShortFilePath(actualApiReviewPath); const expectedApiReviewPath = path.resolve(this._absoluteRootFolder, this.actualConfig.apiReviewFile.apiReviewFolder, apiReviewFilename); const expectedApiReviewShortPath = this._getShortFilePath(expectedApiReviewPath); const actualApiReviewContent = generator.generateApiFileContent(context); // Write the actual file node_core_library_1.FileSystem.writeFile(actualApiReviewPath, actualApiReviewContent, { ensureFolderExists: true }); // Compare it against the expected file if (node_core_library_1.FileSystem.exists(expectedApiReviewPath)) { const expectedApiReviewContent = node_core_library_1.FileSystem.readFile(expectedApiReviewPath); if (!ApiFileGenerator_1.ApiFileGenerator.areEquivalentApiFileContents(actualApiReviewContent, expectedApiReviewContent)) { if (!this._localBuild) { // For production, issue a warning that will break the CI build. this._monitoredLogger.logWarning('You have changed the public API signature for this project.' + ` Please overwrite ${expectedApiReviewShortPath} with a` + ` copy of ${actualApiReviewShortPath}` + ' and then request an API review. See the Git repository README.md for more info.'); } else { // For a local build, just copy the file automatically. this._monitoredLogger.logWarning('You have changed the public API signature for this project.' + ` Updating ${expectedApiReviewShortPath}`); node_core_library_1.FileSystem.writeFile(expectedApiReviewPath, actualApiReviewContent); } } else { this._monitoredLogger.logVerbose(`The API signature is up to date: ${actualApiReviewShortPath}`); } } else { // NOTE: This warning seems like a nuisance, but it has caught genuine mistakes. // For example, when projects were moved into category folders, the relative path for // the API review files ended up in the wrong place. this._monitoredLogger.logError(`The API review file has not been set up.` + ` Do this by copying ${actualApiReviewShortPath}` + ` to ${expectedApiReviewShortPath} and committing it.`); } } this._generateRollupDtsFiles(context); if (this._localBuild) { // For a local build, fail if there were errors (but ignore warnings) return this._monitoredLogger.errorCount === 0; } else { // For a production build, fail if there were any errors or warnings return (this._monitoredLogger.errorCount + this._monitoredLogger.warningCount) === 0; } } _generateRollupDtsFiles(context) { const dtsRollup = this.actualConfig.dtsRollup; if (dtsRollup.enabled) { let mainDtsRollupPath = dtsRollup.mainDtsRollupPath; if (!mainDtsRollupPath) { // If the mainDtsRollupPath is not specified, then infer it from the package.json file if (!context.packageJson.typings) { this._monitoredLogger.logError('Either the "mainDtsRollupPath" setting must be specified,' + ' or else the package.json file must contain a "typings" field.'); return; } // Resolve the "typings" field relative to package.json itself const resolvedTypings = path.resolve(context.packageFolder, context.packageJson.typings); if (dtsRollup.trimming) { if (!node_core_library_1.Path.isUnder(resolvedTypings, dtsRollup.publishFolderForInternal)) { this._monitoredLogger.logError('The "mainDtsRollupPath" setting was not specified.' + ' In this case, the package.json "typings" field must point to a file under' + ' the "publishFolderForInternal": ' + dtsRollup.publishFolderForInternal); return; } mainDtsRollupPath = path.relative(dtsRollup.publishFolderForInternal, resolvedTypings); } else { if (!node_core_library_1.Path.isUnder(resolvedTypings, dtsRollup.publishFolder)) { this._monitoredLogger.logError('The "mainDtsRollupPath" setting was not specified.' + ' In this case, the package.json "typings" field must point to a file under' + ' the "publishFolder": ' + dtsRollup.publishFolder); return; } mainDtsRollupPath = path.relative(dtsRollup.publishFolder, resolvedTypings); } this._monitoredLogger.logVerbose(`The "mainDtsRollupPath" setting was inferred from package.json: ${mainDtsRollupPath}`); } else { this._monitoredLogger.logVerbose(`The "mainDtsRollupPath" is: ${mainDtsRollupPath}`); if (!path.isAbsolute(mainDtsRollupPath)) { this._monitoredLogger.logError('The "mainDtsRollupPath" setting must be a relative path' + ' that can be combined with one of the "publishFolder" settings.'); return; } } const dtsRollupGenerator = new DtsRollupGenerator_1.DtsRollupGenerator(context); dtsRollupGenerator.analyze(); if (dtsRollup.trimming) { this._generateRollupDtsFile(dtsRollupGenerator, path.resolve(context.packageFolder, dtsRollup.publishFolderForPublic, mainDtsRollupPath), DtsRollupGenerator_1.DtsRollupKind.PublicRelease); this._generateRollupDtsFile(dtsRollupGenerator, path.resolve(context.packageFolder, dtsRollup.publishFolderForBeta, mainDtsRollupPath), DtsRollupGenerator_1.DtsRollupKind.BetaRelease); this._generateRollupDtsFile(dtsRollupGenerator, path.resolve(context.packageFolder, dtsRollup.publishFolderForInternal, mainDtsRollupPath), DtsRollupGenerator_1.DtsRollupKind.InternalRelease); } else { this._generateRollupDtsFile(dtsRollupGenerator, path.resolve(context.packageFolder, dtsRollup.publishFolder, mainDtsRollupPath), DtsRollupGenerator_1.DtsRollupKind.InternalRelease); // (no trimming) } } } _generateRollupDtsFile(dtsRollupGenerator, mainDtsRollupFullPath, dtsKind) { this._monitoredLogger.logVerbose(`Writing package typings: ${mainDtsRollupFullPath}`); dtsRollupGenerator.writeTypingsFile(mainDtsRollupFullPath, dtsKind); } _getShortFilePath(absolutePath) { if (!path.isAbsolute(absolutePath)) { throw new Error('Expected absolute path: ' + absolutePath); } return path.relative(this._absoluteRootFolder, absolutePath).replace(/\\/g, '/'); } /** * Update the parsed command line to use paths from the specified TS compiler folder, if * a TS compiler folder is specified. */ _updateCommandLineForTypescriptPackage(commandLine, options) { const DEFAULT_BUILTIN_LIBRARY = 'lib.d.ts'; const OTHER_BUILTIN_LIBRARIES = ['lib.es5.d.ts', 'lib.es6.d.ts']; if (options.typescriptCompilerFolder) { commandLine.options.noLib = true; const compilerLibFolder = path.join(options.typescriptCompilerFolder, 'lib'); let foundBaseLib = false; const filesToAdd = []; for (const libFilename of commandLine.options.lib || []) { if (libFilename === DEFAULT_BUILTIN_LIBRARY) { // Ignore the default lib - it'll get added later continue; } if (OTHER_BUILTIN_LIBRARIES.indexOf(libFilename) !== -1) { foundBaseLib = true; } const libPath = path.join(compilerLibFolder, libFilename); if (!node_core_library_1.FileSystem.exists(libPath)) { throw new Error(`lib ${libFilename} does not exist in the compiler specified in typescriptLibPackage`); } filesToAdd.push(libPath); } if (!foundBaseLib) { // If we didn't find another version of the base lib library, include the default filesToAdd.push(path.join(compilerLibFolder, 'lib.d.ts')); } if (!commandLine.fileNames) { commandLine.fileNames = []; } commandLine.fileNames.push(...filesToAdd); commandLine.options.lib = undefined; } } } /** * The JSON Schema for API Extractor config file (api-extractor-config.schema.json). */ Extractor.jsonSchema = node_core_library_1.JsonSchema.fromFile(path.join(__dirname, './api-extractor.schema.json')); Extractor._defaultConfig = node_core_library_1.JsonFile.load(path.join(__dirname, './api-extractor-defaults.json')); Extractor._declarationFileExtensionRegExp = /\.d\.ts$/i; Extractor._defaultLogger = { logVerbose: (message) => console.log('(Verbose) ' + message), logInfo: (message) => console.log(message), logWarning: (message) => console.warn(colors.yellow(message)), logError: (message) => console.error(colors.red(message)) }; exports.Extractor = Extractor; //# sourceMappingURL=Extractor.js.map