import { join , resolve , parse , basename } from 'path' ; import { promises , readFileSync , lstatSync , existsSync , readdirSync } from 'fs' ; import { GlobSync } from 'glob' ; import { createLogger , flatten , unixEOL } from '@sap-cloud-sdk/util' ; import mock from 'mock-fs' ; import { CompilerOptions } from 'typescript' ; import { readCompilerOptions , transpileDirectory } from '@sap-cloud-sdk/generator-common/internal' ; const { readFile } = promises ; const logger = createLogger ( 'check-public-api' ) ; const pathToTsConfigRoot = join ( __dirname , '../tsconfig.json' ) ; const pathRootNodeModules = resolve ( __dirname , '../node_modules' ) ; export const regexExportedIndex = /\{([\w,]+)\}/g ; export const regexExportedInternal = /\.\/([\w-]+)/g ; function mockFileSystem ( pathToPackage ) { const { pathToSource , pathToTsConfig } = paths ( pathToPackage ) ; mock ( { [ pathToTsConfig ] : mock . load ( pathToTsConfig ) , [ pathToSource ] : mock . load ( pathToSource ) , [ pathRootNodeModules ] : mock . load ( pathRootNodeModules ) , [ pathToTsConfigRoot ] : mock . load ( pathToTsConfigRoot ) } ) ; } function paths ( pathToPackage ) { return { pathToSource : join ( pathToPackage , 'src' ) , pathToTsConfig : join ( pathToPackage , 'tsconfig.json' ) , pathCompiled : 'dist' } ; } param pathToPackage returns async function getCompilerOptions ( pathToPackage ) { const { pathToSource , pathToTsConfig , pathCompiled } = paths ( pathToPackage ) ; const compilerOptions = await readCompilerOptions ( pathToTsConfig ) ; const compilerOptionsRoot = await readCompilerOptions ( pathToTsConfigRoot ) ; return { ... compilerOptionsRoot , ... compilerOptions , stripInternal : true , rootDir : pathToSource , outDir : pathCompiled } ; } param allExportedIndex param allExportedTypes param verbose returns function compareApisAndLog ( allExportedIndex , allExportedTypes , verbose ) { let setsAreEqual = true ; allExportedTypes . forEach ( exportedType => { if ( ! allExportedIndex . find ( nameInIndex => exportedType . name === nameInIndex ) ) { logger . error ( `The ${ exportedType . type } "${ exportedType . name }" in file: ${ exportedType . path } is neither listed in the index.ts nor marked as internal.` ) ; setsAreEqual = false ; } } ) ; allExportedIndex . forEach ( nameInIndex => { if ( ! allExportedTypes . find ( exportedType => exportedType . name === nameInIndex ) ) { logger . error ( `The object "${ nameInIndex }" is exported from the index.ts but marked as @internal.` ) ; setsAreEqual = false ; } } ) ; logger . info ( `We have found ${ allExportedIndex . length } exports.` ) ; if ( verbose ) { logger . info ( `Public api:
    ${ allExportedIndex . sort ( ) . join ( `,${ unixEOL }` ) }` ) ; } return setsAreEqual ; } param pathToPackage export async function checkApiOfPackage ( pathToPackage ) { logger . info ( `Check package: ${ pathToPackage }` ) ; const { pathToSource , pathCompiled } = paths ( pathToPackage ) ; mockFileSystem ( pathToPackage ) ; await transpileDirectory ( pathToSource , await getCompilerOptions ( pathToPackage ) ) ; checkBarrelRecursive ( pathToSource ) ; checkSingleIndexFile ( pathToSource ) ; const allExportedTypes = await parseTypeDefinitionFiles ( pathCompiled ) ; const allExportedIndex = parseBarrelFile ( await readFile ( indexFiles ( pathToSource , 'index.ts' ) [ 0 ] , 'utf8' ) , /\{([\w,]+)\}/g ) ; const setsAreEqual = compareApisAndLog ( allExportedIndex , allExportedTypes , true ) ; mock . restore ( ) ; if ( ! setsAreEqual ) { process . exit ( 1 ) ; } logger . info ( `The index.ts of package ${ pathToPackage } is in sync with the type annotations.` ) ; } param cwd returns export function typeDescriptorPaths ( cwd ) { const files = new GlobSync ( '**/*.d.ts' , { cwd } ) . found ; return files . filter ( file => ! file . includes ( 'index.d.ts' ) ) . map ( file => join ( cwd , file ) ) ; } export interface ExportedObject { name : string ; type : string ; path : string ; } param pathCompiled returns export async function parseTypeDefinitionFiles ( pathCompiled ) { const typeDefinitionPaths = typeDescriptorPaths ( pathCompiled ) ; const result = await Promise . all ( typeDefinitionPaths . map ( async ( pathTypeDefinition ) => { const fileContent = await readFile ( pathTypeDefinition , 'utf8' ) ; const types = parseTypeDefinitionFile ( fileContent ) ; return types . map ( type => ( { path : pathTypeDefinition , ... type } ) ) ; } ) ) ; return flatten ( result ) ; } export function parseTypeDefinitionFile ( fileContent ) { const normalized = fileContent . replace ( /\n+/g , '' ) ; return [ 'function' , 'const' , 'enum' , 'class' , 'abstract class' , 'type' , 'interface' ] . reduce ( ( allObjects , objectType ) => { const regex = objectType === 'interface' ? new RegExp ( `export ${ objectType } (\\w+)` , 'g' ) : new RegExp ( `export declare ${ objectType } (\\w+)` , 'g' ) ; const exported = captureGroupsFromGlobalRegex ( regex , normalized ) . map ( element => ( { name : element , type : objectType } ) ) ; return [ ... allObjects , ... exported ] ; } , [ ] ) ; } param cwd param pattern returns export function indexFiles ( cwd , pattern ) { const files = new GlobSync ( pattern , { cwd } ) . found ; return files . map ( file => join ( cwd , file ) ) ; } param cwd export function checkSingleIndexFile ( cwd ) { const files = indexFiles ( cwd , 'index.ts' ) ; if ( ! files . length ) { throw Error ( `No index.ts file found in ${ cwd }` ) ; } const content = readFileSync ( files [ 0 ] ) ; if ( content . includes ( '*' ) ) { throw new Error ( `There is a '*' in ${ files [ 0 ] }` ) ; } } param fileContent param regex returns export function parseBarrelFile ( fileContent , regex ) { const normalized = fileContent . replace ( /\s+/g , '' ) ; const groups = captureGroupsFromGlobalRegex ( regex , normalized ) ; return flatten ( groups . map ( group => group . split ( ',' ) ) ) ; } function captureGroupsFromGlobalRegex ( regex , str ) { const groups = Array . from ( str . matchAll ( regex ) ) ; return groups . map ( group => group [ 1 ] ) ; } export function checkBarrelRecursive ( cwd ) { readdirSync ( cwd , { withFileTypes : true } ) . filter ( dirent => dirent . isDirectory ( ) ) . forEach ( subDir => { if ( subDir . name !== '__snapshots__' ) { checkBarrelRecursive ( join ( cwd , subDir . name ) ) ; } } ) ; exportAllInBarrel ( cwd , parse ( cwd ) . name === 'src' ? 'internal.ts' : 'index.ts' ) ; } export function exportAllInBarrel ( cwd , barrelFileName ) { const barrelFilePath = join ( cwd , barrelFileName ) ; if ( existsSync ( barrelFilePath ) && lstatSync ( barrelFilePath ) . isFile ( ) ) { const dirContents = new GlobSync ( '*' , { ignore : [ '**/*.spec.ts' , '__snapshots__' , 'internal.ts' , 'index.ts' , 'cli.ts' , 'generator-cli.ts' , '**/*.md' ] , cwd } ) . found . map ( name => basename ( name , '.ts' ) ) ; const exportedFiles = parseBarrelFile ( readFileSync ( barrelFilePath , 'utf8' ) , regexExportedInternal ) ; if ( compareBarrels ( dirContents , exportedFiles , barrelFilePath ) ) { throw Error ( `${ barrelFileName } is not in sync` ) ; } } else { throw Error ( `No ${ barrelFileName } file found in ${ cwd }` ) ; } } function compareBarrels ( dirContents , exportedFiles , barrelFilePath ) { const missingBarrelExports = dirContents . filter ( x => ! exportedFiles . includes ( x ) ) ; missingBarrelExports . forEach ( tsFiles => logger . error ( `${ tsFiles } is not exported in ${ barrelFilePath }` ) ) ; const extraBarrelExports = exportedFiles . filter ( x => ! dirContents . includes ( x ) ) ; extraBarrelExports . forEach ( exports => logger . error ( `"${ exports }" is exported from the ${ barrelFilePath } but does not exist in this directory` ) ) ; return missingBarrelExports . length || extraBarrelExports . length ; }