@microsoft/api-extractor
Version:
Validate, document, and review the exported API for a TypeScript library
352 lines • 20.5 kB
JavaScript
"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