build: refactor ts-api-guardian jsdoc tag handling (#26595)

Allow the jsdoc tag processing to be configured by
type (export, member, param) and by action (required,
banned, toCopy).

This is a pre-requisite to moving over to using `@publicApi`
tags rather than `@stable` and `@experimental`.

PR Close #26595
This commit is contained in:
Pete Bacon Darwin 2018-10-19 10:44:33 +01:00 committed by Alex Rickabaugh
parent 31022cbecf
commit 2ea57cdcc3
3 changed files with 209 additions and 21 deletions

View File

@ -24,6 +24,9 @@ export function startCli() {
const options: SerializationOptions = {
stripExportPattern: [].concat(argv['stripExportPattern']),
allowModuleIdentifiers: [].concat(argv['allowModuleIdentifiers']),
exportTags: {required: [], banned: [], toCopy: ['deprecated', 'experimental']},
memberTags: {required: [], banned: [], toCopy: ['deprecated', 'experimental']},
paramTags: {required: [], banned: [], toCopy: ['deprecated', 'experimental']}
};
for (const error of errors) {

View File

@ -15,6 +15,23 @@ const baseTsOptions: ts.CompilerOptions = {
moduleResolution: ts.ModuleResolutionKind.Classic
};
export interface JsDocTagOptions {
/**
* An array of names of jsdoc tags that must exist.
*/
required?: string[];
/**
* An array of names of jsdoc tags that must not exist.
*/
banned?: string[];
/**
* An array of names of jsdoc tags that will be copied to the serialized code.
*/
toCopy?: string[];
}
export interface SerializationOptions {
/**
* Removes all exports matching the regular expression.
@ -31,6 +48,15 @@ export interface SerializationOptions {
* whitelisting angular.
*/
allowModuleIdentifiers?: string[];
/** The jsdoc tag options for top level exports */
exportTags?: JsDocTagOptions;
/** The jsdoc tag options for properties/methods/etc of exports */
memberTags?: JsDocTagOptions;
/** The jsdoc tag options for parameters of members/functions */
paramTags?: JsDocTagOptions;
}
export type DiagnosticSeverity = 'warn' | 'error' | 'none';
@ -46,6 +72,14 @@ export function publicApiInternal(
// the path needs to be normalized with forward slashes in order to work within Windows.
const entrypoint = path.normalize(fileName).replace(/\\/g, '/');
// Setup default tag options
options = {
...options,
exportTags: applyDefaultTagOptions(options.exportTags),
memberTags: applyDefaultTagOptions(options.memberTags),
paramTags: applyDefaultTagOptions(options.paramTags)
};
if (!entrypoint.match(/\.d\.ts$/)) {
throw new Error(`Source file "${fileName}" is not a declaration file`);
}
@ -115,12 +149,9 @@ class ResolvedDeclarationEmitter {
output += '\n';
}
// Print stability annotation
const sourceText = decl.getSourceFile().text;
const trivia = sourceText.substr(decl.pos, decl.getLeadingTriviaWidth());
const match = stabilityAnnotationPattern.exec(trivia);
if (match) {
output += `/** @${match[1]} */\n`;
const jsdocComment = this.processJsDocTags(decl, this.options.exportTags);
if (jsdocComment) {
output += jsdocComment + '\n';
}
output += stripEmptyLines(this.emitNode(decl)) + '\n';
@ -254,13 +285,13 @@ class ResolvedDeclarationEmitter {
.map(n => this.emitNode(n))
.join('');
// Print stability annotation for fields
// Print stability annotation for fields and parmeters
if (ts.isParameter(node) || node.kind in memberDeclarationOrder) {
const trivia = sourceText.substr(node.pos, node.getLeadingTriviaWidth());
const match = stabilityAnnotationPattern.exec(trivia);
if (match) {
const tagOptions = ts.isParameter(node) ? this.options.paramTags : this.options.memberTags;
const jsdocComment = this.processJsDocTags(node, tagOptions);
if (jsdocComment) {
// Add the annotation after the leading whitespace
output = output.replace(/^(\n\s*)/, `$1/** @${match[1]} */ `);
output = output.replace(/^(\n\s*)/, `$1${jsdocComment} `);
}
}
@ -276,6 +307,56 @@ class ResolvedDeclarationEmitter {
return sourceText.substring(tail, node.end);
}
}
private processJsDocTags(node: ts.Node, tagOptions: JsDocTagOptions) {
const jsDocTags = getJsDocTags(node);
const missingRequiredTags =
tagOptions.required.filter(requiredTag => jsDocTags.every(tag => tag !== requiredTag));
if (missingRequiredTags.length) {
this.diagnostics.push({
type: 'error',
message: createErrorMessage(
node, 'Required jsdoc tags - ' +
missingRequiredTags.map(tag => `"@${tag}"`).join(', ') +
` - are missing on ${getName(node)}.`)
});
}
const bannedTagsFound =
tagOptions.banned.filter(bannedTag => jsDocTags.some(tag => tag === bannedTag));
if (bannedTagsFound.length) {
this.diagnostics.push({
type: 'error',
message: createErrorMessage(
node, 'Banned jsdoc tags - ' + bannedTagsFound.map(tag => `"@${tag}"`).join(', ') +
` - were found on ${getName(node)}.`)
});
}
const tagsToCopy =
jsDocTags.filter(tag => tagOptions.toCopy.some(tagToCopy => tag === tagToCopy));
if (tagsToCopy.length === 1) {
return `/** @${tagsToCopy[0]} */`;
} else if (tagsToCopy.length > 1) {
return '/**\n' + tagsToCopy.map(tag => ` * @${tag}`).join('\n') + ' */\n';
} else {
return '';
}
}
}
const tagRegex = /@(\w+)/g;
function getJsDocTags(node: ts.Node): string[] {
const sourceText = node.getSourceFile().text;
const trivia = sourceText.substr(node.pos, node.getLeadingTriviaWidth());
// We use a hash so that we don't collect duplicate jsdoc tags
// (e.g. if a property has a getter and setter with the same tag).
const jsdocTags: {[key: string]: boolean} = {};
let match: RegExpExecArray;
while (match = tagRegex.exec(trivia)) {
jsdocTags[match[1]] = true;
}
return Object.keys(jsdocTags);
}
function symbolCompareFunction(a: ts.Symbol, b: ts.Symbol) {
@ -299,8 +380,6 @@ const memberDeclarationOrder: {[key: number]: number} = {
[ts.SyntaxKind.MethodDeclaration]: 4
};
const stabilityAnnotationPattern = /@(experimental|stable|deprecated)\b/;
function stripEmptyLines(text: string): string {
return text.split('\n').filter(x => !!x.length).join('\n');
}
@ -349,3 +428,11 @@ function createErrorMessage(node: ts.Node, message: string): string {
function hasModifier(node: ts.Node, modifierKind: ts.SyntaxKind): boolean {
return !!node.modifiers && node.modifiers.some(x => x.kind === modifierKind);
}
function applyDefaultTagOptions(tagOptions: JsDocTagOptions | undefined): JsDocTagOptions {
return {required: [], banned: [], toCopy: [], ...tagOptions};
}
function getName(node: any) {
return '`' + (node.name && node.name.text ? node.name.text : node.getText()) + '`';
}

View File

@ -404,7 +404,7 @@ describe('unit test', () => {
check({'file.d.ts': input}, expected);
});
it('should keep stability annotations of exports in docstrings', () => {
it('should copy specified jsdoc tags of exports in docstrings', () => {
const input = `
/**
* @deprecated This is useless now
@ -428,14 +428,14 @@ describe('unit test', () => {
/** @experimental */
export declare const b: string;
/** @stable */
export declare var c: number;
`;
check({'file.d.ts': input}, expected);
check({'file.d.ts': input}, expected, {exportTags: {toCopy: ['deprecated', 'experimental']}});
});
it('should keep stability annotations of fields in docstrings', () => {
it('should copy specified jsdoc tags of fields in docstrings', () => {
const input = `
/** @otherTag */
export declare class A {
/**
* @stable
@ -443,6 +443,7 @@ describe('unit test', () => {
value: number;
/**
* @experimental
* @otherTag
*/
constructor();
/**
@ -453,12 +454,107 @@ describe('unit test', () => {
`;
const expected = `
export declare class A {
/** @stable */ value: number;
value: number;
/** @experimental */ constructor();
/** @deprecated */ foo(): void;
}
`;
check({'file.d.ts': input}, expected);
check({'file.d.ts': input}, expected, {memberTags: {toCopy: ['deprecated', 'experimental']}});
});
it('should copy specified jsdoc tags of parameters in docstrings', () => {
const input = `
export declare class A {
foo(str: string, /** @deprecated */ value: number): void;
}
`;
const expected = `
export declare class A {
foo(str: string, /** @deprecated */ value: number): void;
}
`;
check({'file.d.ts': input}, expected, {paramTags: {toCopy: ['deprecated', 'experimental']}});
});
it('should throw on using banned jsdoc tags on exports', () => {
const input = `
/**
* @stable
*/
export declare class A {
value: number;
}
`;
checkThrows(
{'file.d.ts': input},
'file.d.ts(4,1): error: Banned jsdoc tags - "@stable" - were found on `A`.',
{exportTags: {banned: ['stable']}});
});
it('should throw on using banned jsdoc tags on fields', () => {
const input = `
export declare class A {
/**
* @stable
*/
value: number;
}
`;
checkThrows(
{'file.d.ts': input},
'file.d.ts(5,3): error: Banned jsdoc tags - "@stable" - were found on `value`.',
{memberTags: {banned: ['stable']}});
});
it('should throw on using banned jsdoc tags on parameters', () => {
const input = `
export declare class A {
foo(/** @stable */ param: number): void;
}
`;
checkThrows(
{'file.d.ts': input},
'file.d.ts(2,22): error: Banned jsdoc tags - "@stable" - were found on `param`.',
{paramTags: {banned: ['stable']}});
});
it('should throw on missing required jsdoc tags on exports', () => {
const input = `
/** @experimental */
export declare class A {
value: number;
}
`;
checkThrows(
{'file.d.ts': input},
'file.d.ts(2,1): error: Required jsdoc tags - "@stable" - are missing on `A`.',
{exportTags: {required: ['stable']}});
});
it('should throw on missing required jsdoc tags on fields', () => {
const input = `
/** @experimental */
export declare class A {
value: number;
}
`;
checkThrows(
{'file.d.ts': input},
'file.d.ts(3,3): error: Required jsdoc tags - "@stable" - are missing on `value`.',
{memberTags: {required: ['stable']}});
});
it('should throw on missing required jsdoc tags on parameters', () => {
const input = `
/** @experimental */
export declare class A {
foo(param: number): void;
}
`;
checkThrows(
{'file.d.ts': input},
'file.d.ts(3,7): error: Required jsdoc tags - "@stable" - are missing on `param`.',
{paramTags: {required: ['stable']}});
});
});
@ -487,8 +583,10 @@ function check(
chai.assert.equal(actual.trim(), stripExtraIndentation(expected).trim());
}
function checkThrows(files: {[name: string]: string}, error: string) {
chai.assert.throws(() => { publicApiInternal(getMockHost(files), 'file.d.ts', {}); }, error);
function checkThrows(
files: {[name: string]: string}, error: string, options: SerializationOptions = {}) {
chai.assert.throws(
() => { publicApiInternal(getMockHost(files), 'file.d.ts', {}, options); }, error);
}
function stripExtraIndentation(text: string) {