Skip to content

Commit 41eeffd

Browse files
authored
[API Extractor] Add support for printing a diff of changed API reports in non-local builds. (#5427)
* Fix an issue where verbose API extractor messages may not be passed to the Heft plugin. * Add diff printing to API Extractor's report issue logging. * Expose an alwaysShowChangedApiReportDiffOnNonLocalBuild option in the api-extractor-heft-plugin configuration file. * Use the alwaysShowChangedApiReportDiffOnNonLocalBuild in the local rig * fixup! Add diff printing to API Extractor's report issue logging.
1 parent cc1d835 commit 41eeffd

File tree

19 files changed

+313
-197
lines changed

19 files changed

+313
-197
lines changed

apps/api-extractor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@rushstack/rig-package": "workspace:*",
4545
"@rushstack/terminal": "workspace:*",
4646
"@rushstack/ts-command-line": "workspace:*",
47+
"diff": "~8.0.2",
4748
"lodash": "~4.17.15",
4849
"minimatch": "10.0.3",
4950
"resolve": "~1.22.1",

apps/api-extractor/src/api/ConsoleMessageId.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export enum ConsoleMessageId {
6161
*/
6262
ApiReportNotCopied = 'console-api-report-not-copied',
6363

64+
/**
65+
* Changes to the API report:
66+
* ___
67+
*/
68+
ApiReportDiff = 'console-api-report-diff',
69+
6470
/**
6571
* "You have changed the public API signature for this project. Updating ___"
6672
*/

apps/api-extractor/src/api/Extractor.ts

Lines changed: 95 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { ApiPackage } from '@microsoft/api-extractor-model';
1111
import { TSDocConfigFile } from '@microsoft/tsdoc-config';
1212
import {
1313
FileSystem,
14-
type NewlineKind,
14+
NewlineKind,
1515
PackageJsonLookup,
1616
type IPackageJson,
1717
type INodePackageJson,
@@ -91,6 +91,15 @@ export interface IExtractorInvokeOptions {
9191
* the STDERR/STDOUT console.
9292
*/
9393
messageCallback?: (message: ExtractorMessage) => void;
94+
95+
/**
96+
* If true, then any differences between the actual and expected API reports will be
97+
* printed on the console.
98+
*
99+
* @remarks
100+
* The diff is not printed if the expected API report file has not been created yet.
101+
*/
102+
printApiReportDiff?: boolean;
94103
}
95104

96105
/**
@@ -192,36 +201,52 @@ export class Extractor {
192201
* Invoke API Extractor using an already prepared `ExtractorConfig` object.
193202
*/
194203
public static invoke(extractorConfig: ExtractorConfig, options?: IExtractorInvokeOptions): ExtractorResult {
195-
if (!options) {
196-
options = {};
197-
}
198-
199-
const localBuild: boolean = options.localBuild || false;
200-
201-
let compilerState: CompilerState | undefined;
202-
if (options.compilerState) {
203-
compilerState = options.compilerState;
204-
} else {
205-
compilerState = CompilerState.create(extractorConfig, options);
206-
}
204+
const {
205+
packageFolder,
206+
messages,
207+
tsdocConfiguration,
208+
tsdocConfigFile: { filePath: tsdocConfigFilePath, fileNotFound: tsdocConfigFileNotFound },
209+
apiJsonFilePath,
210+
newlineKind,
211+
reportTempFolder,
212+
reportFolder,
213+
apiReportEnabled,
214+
reportConfigs,
215+
testMode,
216+
rollupEnabled,
217+
publicTrimmedFilePath,
218+
alphaTrimmedFilePath,
219+
betaTrimmedFilePath,
220+
untrimmedFilePath,
221+
tsdocMetadataEnabled,
222+
tsdocMetadataFilePath
223+
} = extractorConfig;
224+
const {
225+
localBuild = false,
226+
compilerState = CompilerState.create(extractorConfig, options),
227+
messageCallback,
228+
showVerboseMessages = false,
229+
showDiagnostics = false,
230+
printApiReportDiff = false
231+
} = options ?? {};
207232

208233
const sourceMapper: SourceMapper = new SourceMapper();
209234

210235
const messageRouter: MessageRouter = new MessageRouter({
211-
workingPackageFolder: extractorConfig.packageFolder,
212-
messageCallback: options.messageCallback,
213-
messagesConfig: extractorConfig.messages || {},
214-
showVerboseMessages: !!options.showVerboseMessages,
215-
showDiagnostics: !!options.showDiagnostics,
216-
tsdocConfiguration: extractorConfig.tsdocConfiguration,
236+
workingPackageFolder: packageFolder,
237+
messageCallback,
238+
messagesConfig: messages || {},
239+
showVerboseMessages,
240+
showDiagnostics,
241+
tsdocConfiguration,
217242
sourceMapper
218243
});
219244

220-
if (extractorConfig.tsdocConfigFile.filePath && !extractorConfig.tsdocConfigFile.fileNotFound) {
221-
if (!Path.isEqual(extractorConfig.tsdocConfigFile.filePath, ExtractorConfig._tsdocBaseFilePath)) {
245+
if (tsdocConfigFilePath && !tsdocConfigFileNotFound) {
246+
if (!Path.isEqual(tsdocConfigFilePath, ExtractorConfig._tsdocBaseFilePath)) {
222247
messageRouter.logVerbose(
223248
ConsoleMessageId.UsingCustomTSDocConfig,
224-
'Using custom TSDoc config from ' + extractorConfig.tsdocConfigFile.filePath
249+
`Using custom TSDoc config from ${tsdocConfigFilePath}`
225250
);
226251
}
227252
}
@@ -243,9 +268,7 @@ export class Extractor {
243268

244269
messageRouter.logDiagnosticHeader('TSDoc configuration');
245270
// Convert the TSDocConfiguration into a tsdoc.json representation
246-
const combinedConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromParser(
247-
extractorConfig.tsdocConfiguration
248-
);
271+
const combinedConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromParser(tsdocConfiguration);
249272
const serializedTSDocConfig: object = MessageRouter.buildJsonDumpObject(
250273
combinedConfigFile.saveToObject()
251274
);
@@ -273,17 +296,14 @@ export class Extractor {
273296
}
274297

275298
if (modelBuilder.docModelEnabled) {
276-
messageRouter.logVerbose(
277-
ConsoleMessageId.WritingDocModelFile,
278-
'Writing: ' + extractorConfig.apiJsonFilePath
279-
);
280-
apiPackage.saveToJsonFile(extractorConfig.apiJsonFilePath, {
299+
messageRouter.logVerbose(ConsoleMessageId.WritingDocModelFile, `Writing: ${apiJsonFilePath}`);
300+
apiPackage.saveToJsonFile(apiJsonFilePath, {
281301
toolPackage: Extractor.packageName,
282302
toolVersion: Extractor.version,
283303

284-
newlineConversion: extractorConfig.newlineKind,
304+
newlineConversion: newlineKind,
285305
ensureFolderExists: true,
286-
testMode: extractorConfig.testMode
306+
testMode
287307
});
288308
}
289309

@@ -292,53 +312,51 @@ export class Extractor {
292312
collector,
293313
extractorConfig,
294314
messageRouter,
295-
extractorConfig.reportTempFolder,
296-
extractorConfig.reportFolder,
315+
reportTempFolder,
316+
reportFolder,
297317
reportConfig,
298-
localBuild
318+
localBuild,
319+
printApiReportDiff
299320
);
300321
}
301322

302323
let anyReportChanged: boolean = false;
303-
if (extractorConfig.apiReportEnabled) {
304-
for (const reportConfig of extractorConfig.reportConfigs) {
324+
if (apiReportEnabled) {
325+
for (const reportConfig of reportConfigs) {
305326
anyReportChanged = writeApiReport(reportConfig) || anyReportChanged;
306327
}
307328
}
308329

309-
if (extractorConfig.rollupEnabled) {
330+
if (rollupEnabled) {
310331
Extractor._generateRollupDtsFile(
311332
collector,
312-
extractorConfig.publicTrimmedFilePath,
333+
publicTrimmedFilePath,
313334
DtsRollupKind.PublicRelease,
314-
extractorConfig.newlineKind
335+
newlineKind
315336
);
316337
Extractor._generateRollupDtsFile(
317338
collector,
318-
extractorConfig.alphaTrimmedFilePath,
339+
alphaTrimmedFilePath,
319340
DtsRollupKind.AlphaRelease,
320-
extractorConfig.newlineKind
341+
newlineKind
321342
);
322343
Extractor._generateRollupDtsFile(
323344
collector,
324-
extractorConfig.betaTrimmedFilePath,
345+
betaTrimmedFilePath,
325346
DtsRollupKind.BetaRelease,
326-
extractorConfig.newlineKind
347+
newlineKind
327348
);
328349
Extractor._generateRollupDtsFile(
329350
collector,
330-
extractorConfig.untrimmedFilePath,
351+
untrimmedFilePath,
331352
DtsRollupKind.InternalRelease,
332-
extractorConfig.newlineKind
353+
newlineKind
333354
);
334355
}
335356

336-
if (extractorConfig.tsdocMetadataEnabled) {
357+
if (tsdocMetadataEnabled) {
337358
// Write the tsdoc-metadata.json file for this project
338-
PackageMetadataManager.writeTsdocMetadataFile(
339-
extractorConfig.tsdocMetadataFilePath,
340-
extractorConfig.newlineKind
341-
);
359+
PackageMetadataManager.writeTsdocMetadataFile(tsdocMetadataFilePath, newlineKind);
342360
}
343361

344362
// Show all the messages that we collected during analysis
@@ -373,6 +391,7 @@ export class Extractor {
373391
* @param reportDirectoryPath - The path to the directory under which the existing report file is located, and to
374392
* which the new report will be written post-comparison.
375393
* @param reportConfig - API report configuration, including its file name and {@link ApiReportVariant}.
394+
* @param printApiReportDiff - {@link IExtractorInvokeOptions.printApiReportDiff}
376395
*
377396
* @returns Whether or not the newly generated report differs from the existing report (if one exists).
378397
*/
@@ -383,7 +402,8 @@ export class Extractor {
383402
reportTempDirectoryPath: string,
384403
reportDirectoryPath: string,
385404
reportConfig: IExtractorConfigApiReport,
386-
localBuild: boolean
405+
localBuild: boolean,
406+
printApiReportDiff: boolean
387407
): boolean {
388408
let apiReportChanged: boolean = false;
389409

@@ -411,7 +431,9 @@ export class Extractor {
411431

412432
// Compare it against the expected file
413433
if (FileSystem.exists(expectedApiReportPath)) {
414-
const expectedApiReportContent: string = FileSystem.readFile(expectedApiReportPath);
434+
const expectedApiReportContent: string = FileSystem.readFile(expectedApiReportPath, {
435+
convertLineEndings: NewlineKind.Lf
436+
});
415437

416438
if (
417439
!ApiReportGenerator.areEquivalentApiFileContents(actualApiReportContent, expectedApiReportContent)
@@ -439,6 +461,26 @@ export class Extractor {
439461
convertLineEndings: extractorConfig.newlineKind
440462
});
441463
}
464+
465+
if (messageRouter.showVerboseMessages || printApiReportDiff) {
466+
const Diff: typeof import('diff') = require('diff');
467+
const patch: import('diff').StructuredPatch = Diff.structuredPatch(
468+
expectedApiReportShortPath,
469+
actualApiReportShortPath,
470+
expectedApiReportContent,
471+
actualApiReportContent
472+
);
473+
const logFunction:
474+
| (typeof MessageRouter.prototype)['logWarning']
475+
| (typeof MessageRouter.prototype)['logVerbose'] = printApiReportDiff
476+
? messageRouter.logWarning.bind(messageRouter)
477+
: messageRouter.logVerbose.bind(messageRouter);
478+
479+
logFunction(
480+
ConsoleMessageId.ApiReportDiff,
481+
'Changes to the API report:\n\n' + Diff.formatPatch(patch)
482+
);
483+
}
442484
} else {
443485
messageRouter.logVerbose(
444486
ConsoleMessageId.ApiReportUnchanged,

apps/api-extractor/src/cli/RunAction.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ import { ExtractorConfig, type IExtractorConfigPrepareOptions } from '../api/Ext
2424

2525
export class RunAction extends CommandLineAction {
2626
private readonly _configFileParameter: CommandLineStringParameter;
27-
private readonly _localParameter: CommandLineFlagParameter;
28-
private readonly _verboseParameter: CommandLineFlagParameter;
27+
private readonly _localFlag: CommandLineFlagParameter;
28+
private readonly _verboseFlag: CommandLineFlagParameter;
2929
private readonly _diagnosticsParameter: CommandLineFlagParameter;
30-
private readonly _typescriptCompilerFolder: CommandLineStringParameter;
30+
private readonly _typescriptCompilerFolderParameter: CommandLineStringParameter;
31+
private readonly _printApiReportDiffFlag: CommandLineFlagParameter;
3132

3233
public constructor(parser: ApiExtractorCommandLine) {
3334
super({
@@ -43,7 +44,7 @@ export class RunAction extends CommandLineAction {
4344
description: `Use the specified ${ExtractorConfig.FILENAME} file path, rather than guessing its location`
4445
});
4546

46-
this._localParameter = this.defineFlagParameter({
47+
this._localFlag = this.defineFlagParameter({
4748
parameterLongName: '--local',
4849
parameterShortName: '-l',
4950
description:
@@ -53,7 +54,7 @@ export class RunAction extends CommandLineAction {
5354
' report file is automatically copied in a local build.'
5455
});
5556

56-
this._verboseParameter = this.defineFlagParameter({
57+
this._verboseFlag = this.defineFlagParameter({
5758
parameterLongName: '--verbose',
5859
parameterShortName: '-v',
5960
description: 'Show additional informational messages in the output.'
@@ -66,7 +67,7 @@ export class RunAction extends CommandLineAction {
6667
' This flag also enables the "--verbose" flag.'
6768
});
6869

69-
this._typescriptCompilerFolder = this.defineStringParameter({
70+
this._typescriptCompilerFolderParameter = this.defineStringParameter({
7071
parameterLongName: '--typescript-compiler-folder',
7172
argumentName: 'PATH',
7273
description:
@@ -76,13 +77,21 @@ export class RunAction extends CommandLineAction {
7677
' "--typescriptCompilerFolder" option to specify the folder path where you installed the TypeScript package,' +
7778
" and API Extractor's compiler will use those system typings instead."
7879
});
80+
81+
this._printApiReportDiffFlag = this.defineFlagParameter({
82+
parameterLongName: '--print-api-report-diff',
83+
description:
84+
'If provided, then any differences between the actual and expected API reports will be ' +
85+
'printed on the console. Note that the diff is not printed if the expected API report file has not been ' +
86+
'created yet.'
87+
});
7988
}
8089

8190
protected override async onExecuteAsync(): Promise<void> {
8291
const lookup: PackageJsonLookup = new PackageJsonLookup();
8392
let configFilename: string;
8493

85-
let typescriptCompilerFolder: string | undefined = this._typescriptCompilerFolder.value;
94+
let typescriptCompilerFolder: string | undefined = this._typescriptCompilerFolderParameter.value;
8695
if (typescriptCompilerFolder) {
8796
typescriptCompilerFolder = path.normalize(typescriptCompilerFolder);
8897

@@ -93,17 +102,17 @@ export class RunAction extends CommandLineAction {
93102
: undefined;
94103
if (!typescriptCompilerPackageJson) {
95104
throw new Error(
96-
`The path specified in the ${this._typescriptCompilerFolder.longName} parameter is not a package.`
105+
`The path specified in the ${this._typescriptCompilerFolderParameter.longName} parameter is not a package.`
97106
);
98107
} else if (typescriptCompilerPackageJson.name !== 'typescript') {
99108
throw new Error(
100-
`The path specified in the ${this._typescriptCompilerFolder.longName} parameter is not a TypeScript` +
109+
`The path specified in the ${this._typescriptCompilerFolderParameter.longName} parameter is not a TypeScript` +
101110
' compiler package.'
102111
);
103112
}
104113
} else {
105114
throw new Error(
106-
`The path specified in the ${this._typescriptCompilerFolder.longName} parameter does not exist.`
115+
`The path specified in the ${this._typescriptCompilerFolderParameter.longName} parameter does not exist.`
107116
);
108117
}
109118
}
@@ -136,10 +145,11 @@ export class RunAction extends CommandLineAction {
136145
}
137146

138147
const extractorResult: ExtractorResult = Extractor.invoke(extractorConfig, {
139-
localBuild: this._localParameter.value,
140-
showVerboseMessages: this._verboseParameter.value,
148+
localBuild: this._localFlag.value,
149+
showVerboseMessages: this._verboseFlag.value,
141150
showDiagnostics: this._diagnosticsParameter.value,
142-
typescriptCompilerFolder: typescriptCompilerFolder
151+
typescriptCompilerFolder: typescriptCompilerFolder,
152+
printApiReportDiff: this._printApiReportDiffFlag.value
143153
});
144154

145155
if (extractorResult.succeeded) {

apps/api-extractor/src/collector/MessageRouter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ export class MessageRouter {
422422

423423
/**
424424
* This returns all remaining messages that were flagged with `addToApiReportFile`, but which were not
425-
* retreieved using `fetchAssociatedMessagesForReviewFile()`.
425+
* retrieved using `fetchAssociatedMessagesForReviewFile()`.
426426
*/
427427
public fetchUnassociatedMessagesForReviewFile(): ExtractorMessage[] {
428428
const messagesForApiReportFile: ExtractorMessage[] = [];

0 commit comments

Comments
 (0)