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:
		
							parent
							
								
									31022cbecf
								
							
						
					
					
						commit
						2ea57cdcc3
					
				| @ -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) { | ||||
|  | ||||
| @ -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()) + '`'; | ||||
| } | ||||
| @ -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) { | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user