tools/@aws-cdk/construct-metadata-updater/lib/metadata-updater.ts (533 lines of code) (raw):

import { ClassDeclaration, IndentationText, Project, PropertyDeclaration, QuoteKind, SourceFile, Symbol, SyntaxKind } from "ts-morph"; import * as path from "path"; import * as fs from "fs"; // import { exec } from "child_process"; // import SyntaxKind = ts.SyntaxKind; const DIRECTORIES_TO_SKIP = [ "node_modules", "dist", "build", "decdk", "awslint", "test", ]; interface ResourceClass { sourceFile: SourceFile; filePath: string; node: ClassDeclaration; className: string; fqnClassName: string; } export abstract class MetadataUpdater { protected project: Project; constructor(dir: string) { const projectDir = path.resolve(__dirname, dir); // Initialize a ts-morph Project this.project = new Project({ tsConfigFilePath: path.resolve(__dirname, "../tsconfig.json"), manipulationSettings: { quoteKind: QuoteKind.Single, indentationText: IndentationText.TwoSpaces }, }); this.project.addSourceFilesAtPaths(this.readTypescriptFiles(projectDir)); console.log("Transformation complete."); } public abstract execute(): void; /** * Recursively collect all .ts files from a given directory. */ private readTypescriptFiles(dir: string, filesList: string[] = []) { const files = fs.readdirSync(dir); files.forEach((file) => { const filePath = path.join(dir, file); if (fs.statSync(filePath).isDirectory()) { // Check if this directory is in the list of directories to skip if (!DIRECTORIES_TO_SKIP.includes(file)) { this.readTypescriptFiles(filePath, filesList); } } else if ( filePath.endsWith(".ts") && !filePath.endsWith(".generated.ts") && !filePath.endsWith(".d.ts") && !file.includes("test") ) { filesList.push(filePath); } }); return filesList; } /** * Recursively checks if a given type is a descendant of 'Resource'. */ private isDescendantOfResource(type: any): boolean { // Check if the current type is 'Resource' if (type.getSymbol().getName() === 'Resource') { return true; } // Get the base types (parent types) const baseTypes = type.getBaseTypes(); for (const baseType of baseTypes) { if (this.isDescendantOfResource(baseType)) { return true; } } // If no base type is 'Resource', return false return false; } /** * Parse and transform a file using ts-morph. */ protected getCdkResourceClasses(filePath: string): ResourceClass[] { const sourceFile = this.project.getSourceFile(filePath); if (!sourceFile) return []; const resourceClasses: ResourceClass[] = []; sourceFile.forEachChild((node) => { if (node instanceof ClassDeclaration) { const symbol = node.getSymbol(); if (symbol) { const className = symbol.getName(); // Correct way to get the name const fqnClassName = symbol.getFullyQualifiedName(); // Check if the class is abstract by inspecting modifiers const isAbstract = node.getModifiers()?.some((mod) => mod.getText() === "abstract"); if (isAbstract) { return; } // Check if the class or its subclasses extends Resource const type = node.getType(); if (this.isDescendantOfResource(type)) { resourceClasses.push({ sourceFile, filePath, node, className, fqnClassName }); } } } }); return resourceClasses; } /** * Write the file content for the enum metadats. * @param outputPath The file to write to * @param values The values, as a nested dictionary, to write. */ protected writeFileContent(outputPath: string, values: Record<string, Record<string, (string | number)[]>> = {}) { // Sort the keys of the enumlikes object const sortedValues = Object.keys(values).sort().reduce<Record<string, Record<string, (string | number)[]>>>((acc, key) => { acc[key] = values[key]; return acc; }, {}); const content = JSON.stringify(sortedValues, null, 2); // Write the generated file fs.writeFileSync(outputPath, content); } } export class ConstructsUpdater extends MetadataUpdater { constructor(dir: string) { super(dir); } public execute() { // Process each file in the project this.project.getSourceFiles().forEach((sourceFile) => { const classes = this.getCdkResourceClasses(sourceFile.getFilePath()); for (const resource of classes) { this.addImportAndMetadataStatement(resource.sourceFile, resource.filePath, resource.node); } }); } /** * Add the import statement for MetadataType to the file. */ private addImportAndMetadataStatement(sourceFile: any, filePath: string, node: any) { const ret = this.addLineInConstructor(sourceFile, node); if (!ret) { return; } const absoluteFilePath = path.resolve(filePath); const absoluteTargetPath = path.resolve(__dirname, '../../../../packages/aws-cdk-lib/core/lib/metadata-resource.ts'); let relativePath = path.relative(path.dirname(absoluteFilePath), absoluteTargetPath).replace(/\\/g, "/").replace(/.ts/, ""); if (absoluteFilePath.includes('@aws-cdk')) { relativePath = 'aws-cdk-lib/core/lib/metadata-resource' } // Check if an import from 'metadata-resource' already exists const existingImport = sourceFile.getImportDeclarations().find((stmt: any) => { return stmt.getModuleSpecifier().getText().includes('/metadata-resource'); }); if (existingImport) { // Check if 'MethodMetadata' is already imported const namedImports = existingImport.getNamedImports().map((imp: any) => imp.getName()); if (!namedImports.includes("addConstructMetadata")) { existingImport.addNamedImport({ name: "addConstructMetadata" }); console.log(`Merged import for addConstructMetadata in file: ${filePath}`); } } else { // Find the correct insertion point (after the last import before the new one) const importDeclarations = sourceFile.getImportDeclarations(); let insertIndex = importDeclarations.length; // Default to appending for (let i = importDeclarations.length - 1; i >= 0; i--) { const existingImport = importDeclarations[i].getModuleSpecifier().getLiteralText(); // Insert the new import before the first one that is lexicographically greater if (existingImport.localeCompare(relativePath) > 0) { insertIndex = i; } else { break; } } // Insert the new import at the correct index sourceFile.insertImportDeclaration(insertIndex, { moduleSpecifier: relativePath, namedImports: [{ name: "addConstructMetadata" }], }); console.log(`Added import for addConstructMetadata in file: ${filePath} with relative path: ${relativePath}`); } // Write the updated file back to disk sourceFile.saveSync(); } /** * Add the line of code 'this.node.addMetadata(...)' inside the class constructor. */ private addLineInConstructor(sourceFile: any, classDeclaration: ClassDeclaration): boolean { const constructor = classDeclaration.getConstructors()[0]; // Assuming there's only one constructor if (constructor) { const parameters = constructor.getParameters(); const parameterCount = parameters.length; // Only continue if there's at least 3 parameters. if (parameterCount <= 2) { return false; } // Check if the statement already exists const statements = constructor.getStatements(); const hasMetadataCall = statements.some(statement => statement.getText().includes('addConstructMetadata(') ); if (hasMetadataCall) { return true; } // Find the super() call, if it exists const superCall = constructor.getStatements().find(statement => statement.getText().includes('super(') ); const propName = parameters[2].getName(); // If a super() call exists, find its index and insert after it if (superCall) { const superCallIndex = constructor.getStatements().indexOf(superCall); constructor.insertStatements(superCallIndex + 1, writer => { writer.setIndentationLevel(0); writer.write(' // Enhanced CDK Analytics Telemetry\n'); writer.write(` addConstructMetadata(this, ${propName});`); }); console.log(`Added 'addConstructMetadata();' after the 'super()' in the constructor of class: ${classDeclaration.getName()}`); } else { // If no super() call exists, just add the line at the top constructor.insertStatements(0, writer => { writer.setIndentationLevel(0); writer.write(' // Enhanced CDK Analytics Telemetry\n'); writer.write(` addConstructMetadata(this, ${propName});`); }); console.log(`No 'super()' found. Added 'this.node.addMetadata();' at the top of the constructor for class: ${classDeclaration.getName()}`); } } else { console.log(`No constructor found for class: ${classDeclaration.getName()}`); return false; } sourceFile.saveSync(); return true; } } export class PropertyUpdater extends MetadataUpdater { private classProps: Record<string, Record<string, any>>; constructor(dir: string) { super(dir); this.classProps = {}; } public execute(): void { // Process each file in the project this.project.getSourceFiles().forEach((sourceFile) => { const classes = this.getCdkResourceClasses(sourceFile.getFilePath()); for (const resource of classes) { this.extractConstructorProps(resource.filePath, resource.node, resource.className) this.extractMethodProps(resource.filePath, resource.node, resource.className) } }); this.generateFileContent(); } private extractMethodProps(filePath: string, classDeclaration: ClassDeclaration, className: string) { // Get module name from file path const moduleName = this.getModuleName(filePath); const methods: Record<string, any> = {}; classDeclaration.getMethods().forEach((method) => { if (method.hasModifier(SyntaxKind.PublicKeyword) && !method.hasModifier(SyntaxKind.StaticKeyword)) { const methodName = method.getName(); // Extract parameters with their types const parameters = method.getParameters().map(param => this.getPropertyType(param.getType())); methods[methodName] = parameters; console.log(`Module: ${moduleName}, Method: ${methodName}, Params:`, parameters); } }); if (!methods) { return; } this.classProps[moduleName] = this.classProps[moduleName] || {}; // Ensure class exists within the module this.classProps[moduleName][className] = { ...this.classProps[moduleName][className], ...methods, // Merge with new methods }; } private extractConstructorProps(filePath: string, node: ClassDeclaration, className: string) { // Get module name from file path const moduleName = this.getModuleName(filePath); // Parse Constructor parameters const props = this.parseConstructorProps(node, className); if (!props) { return; } const content = this.classProps[moduleName] || {}; this.classProps[moduleName] = { ...content, ...props, }; } private generateFileContent() { const template = `/* eslint-disable quote-props */ /* eslint-disable @stylistic/comma-dangle */ /* * Do not edit this file manually. To prevent misconfiguration, this file * should only be modified by an automated GitHub workflow, that ensures * that the regions present in this list correspond to all the regions * where we have the AWS::CDK::Metadata handler deployed. * * See: https://github.com/aws/aws-cdk/issues/27189 */ export const AWS_CDK_CONSTRUCTOR_PROPS: { [key: string]: any } = $PROPS; `; // Convert the enums object to a JSON string const jsonContent = JSON.stringify(this.classProps, null, 2).replace(/"/g, "'"); // Replace the placeholder with the JSON object const content = template.replace("$PROPS", jsonContent); const outputPath = path.resolve( __dirname, "../../../../packages/aws-cdk-lib/core/lib/analytics-data-source/classes.ts" ); // Write the generated file fs.writeFileSync(outputPath, content); console.log(`Metadata file written to: ${outputPath}`); } // Helper method to extract module name from file path private getModuleName(filePath: string): string { const pathParts = filePath.split('/'); // Assuming file paths are like '/packages/aws-cdk-lib/aws-lambda/Function.ts' const moduleName = pathParts.slice(pathParts.length - 4, pathParts.length - 2).join('.'); return moduleName; } private getPropertyType(type: any, processedTypes: Set<string> = new Set()): any { if (type.isBoolean()) { return type.getText(); } if (type.isUnion()) { // Get all types in the union and find the first non-undefined type // CDK doesn't support complex union type so we can safely get the first // non-undefined type const unionTypes = type.getUnionTypes(); type = unionTypes.find((t: any) => t.getText() !== 'undefined') || type; if (type.isLiteral() && (type.getText() === 'true' || type.getText() === 'false')) { return 'boolean'; } } const symbol = type.getSymbol(); if (symbol) { const declarations = symbol.getDeclarations(); if (declarations.length > 0) { const decl = declarations[0]; // Check if the type is an Enum Member if (decl.getKindName() === 'EnumMember') { const parent = decl.getParent(); // Get the parent of the Enum Member if (parent && parent.getKindName() === 'EnumDeclaration') { const enumDecl = parent.asKindOrThrow(SyntaxKind.EnumDeclaration); const enumName = enumDecl.getName(); return enumName; // Return the name of the parent enum } } } } if (type.isArray()) { // If it's an array, get the type of the array elements const elementType = type.getArrayElementType(); if (elementType) { return this.getPropertyType(elementType, processedTypes); // Recursively resolve the element type } return '*'; } if (type.isClass() || type.isInterface()) { // Generate a unique identifier for the type to track its processed state const typeId = type?.getSymbol()?.getFullyQualifiedName(); // If the type has already been processed, avoid recursion (cycle detection) if (typeId && processedTypes.has(typeId)) { // TODO: maybe use the cache instead return undefined; } // Add this type to the processed set if (typeId) { processedTypes.add(typeId); } if (type.isClass()) { // Redact class object return '*' } else { // Handle the case where the type is a class or interface return this.resolveInterfaceType(type, processedTypes); } } return '*'; } private resolveInterfaceType(type: any, processedTypes: Set<string>): any { // If it's a reference to another interface type, resolve its properties recursively const symbol = type.getSymbol(); if (symbol) { const declarations = symbol.getDeclarations(); if (declarations.length > 0) { const firstDeclaration = declarations[0]; const members = firstDeclaration.getType().getProperties(); const resolvedObject: Record<string, any> = {}; members.forEach((member: Symbol) => { const memberType = member.getValueDeclaration()?.getType() || member.getDeclaredType(); if (memberType.getCallSignatures().length > 0) { return; } const propName = member.getName(); const nestedType = this.getPropertyType(memberType, processedTypes); if (nestedType) { resolvedObject[propName] = nestedType; } }); return Object.keys(resolvedObject).length === 0 ? '*' : resolvedObject; } } return undefined; // If unable to resolve, return undefined } private parseConstructorProps(node: ClassDeclaration, className: string) { const constructor = node.getConstructors()?.[0]; if (constructor) { const parameters = constructor.getParameters(); const props = parameters[2]; if (props) { const type = props.getType(); if (type?.isObject()) { const properties = type.getProperties(); const propertyTypes: Record<string, any> = {}; properties.forEach((property: Symbol) => { const propName = property.getName(); const nestedType = this.getPropertyType(property.getValueDeclaration()?.getType()); if (nestedType) { propertyTypes[propName] = nestedType; } }); return { [className]: propertyTypes }; } } } return undefined; } } export class EnumsUpdater extends MetadataUpdater { constructor(dir: string) { super(dir); } /** * Parse the repository for any enum type values and generate a JSON blueprint. */ public execute() { const enumBlueprint: Record<string, (string | number)[]> = {}; const moduleEnumBlueprint: Record<string, Record<string, (string | number)[]>> = {}; this.project.getSourceFiles().forEach((sourceFile) => { const sourceFileName: string = sourceFile.getFilePath().split("/aws-cdk/")[1] let fileBlueprint: Record<string, (string | number)[]> = {}; sourceFile.forEachChild((node) => { if (node.getKindName() === "EnumDeclaration") { const enumDeclaration = node.asKindOrThrow(SyntaxKind.EnumDeclaration); const enumName = enumDeclaration.getName(); // Directly access the values of the enum members const enumValues = enumDeclaration.getMembers() .map((member) => member.getValue()) // Access the enum value directly .filter((value) => value !== undefined); // Filter out undefined values // Add to the blueprint enumBlueprint[enumName] = enumValues; fileBlueprint[enumName] = enumValues; } }); if (Object.values(fileBlueprint).length > 0) { moduleEnumBlueprint[sourceFileName] = fileBlueprint; } }); // Generate the file content const content = this.generateFileContent(enumBlueprint); const outputPath = path.resolve( __dirname, "../../../../packages/aws-cdk-lib/core/lib/analytics-data-source/enums.ts" ); const moduleOutputPath = path.resolve( __dirname, "../../../../packages/aws-cdk-lib/core/lib/analytics-data-source/enums/module-enums.json" ); // Write the generated file fs.writeFileSync(outputPath, content); console.log(`Metadata file written to: ${outputPath}`); this.writeFileContent(moduleOutputPath, moduleEnumBlueprint); console.log(`Metadata file written to: ${moduleOutputPath}`); } /** * Generate the file content for the enum metadats. */ private generateFileContent(enums: Record<string, (string | number)[]> = {}): string { const template = `/* eslint-disable quote-props */ /* eslint-disable @stylistic/comma-dangle */ /* eslint-disable @cdklabs/no-literal-partition */ /* * Do not edit this file manually. To prevent misconfiguration, this file * should only be modified by an automated GitHub workflow, that ensures * that the ENUMs present in this list * */ export const AWS_CDK_ENUMS: { [key: string]: any } = $ENUMS; `; // Sort the keys of the enums object const sortedEnums = Object.keys(enums).sort().reduce<Record<string, (string | number)[]>>((acc, key) => { acc[key] = enums[key]; return acc; }, {}); const jsonContent = JSON.stringify(sortedEnums, null, 2).replace(/"/g, "'"); // Replace the placeholder with the JSON object return template.replace("$ENUMS", jsonContent); } } export class MethodsUpdater extends MetadataUpdater { constructor(dir: string) { super(dir); } public execute() { // Process each file in the project this.project.getSourceFiles().forEach((sourceFile) => { const classes = this.getCdkResourceClasses(sourceFile.getFilePath()); for (const resource of classes) { this.addImportsAndDecorators(resource.sourceFile, resource.filePath, resource.node); } }); } /** * Add the import statement for MetadataType to the file. * Add decorators @MetadataMethod() to public non-static methods */ private addImportsAndDecorators(sourceFile: any, filePath: string, node: any) { const ret = this.addDecorators(sourceFile, node); if (!ret) { return; } const absoluteFilePath = path.resolve(filePath); const absoluteTargetPath = path.resolve(__dirname, '../../../../packages/aws-cdk-lib/core/lib/metadata-resource.ts'); let relativePath = path.relative(path.dirname(absoluteFilePath), absoluteTargetPath).replace(/\\/g, "/").replace(/.ts/, ""); if (absoluteFilePath.includes('@aws-cdk')) { relativePath = 'aws-cdk-lib/core/lib/metadata-resource' } // Check if an import from 'metadata-resource' already exists const existingImport = sourceFile.getImportDeclarations().find((stmt: any) => { return stmt.getModuleSpecifier().getText().includes('/metadata-resource'); }); if (existingImport) { // Check if 'MethodMetadata' is already imported const namedImports = existingImport.getNamedImports().map((imp: any) => imp.getName()); if (!namedImports.includes("MethodMetadata")) { existingImport.addNamedImport({ name: "MethodMetadata" }); console.log(`Merged import for MethodMetadata in file: ${filePath}`); } } else { // Find the correct insertion point (after the last import before the new one) const importDeclarations = sourceFile.getImportDeclarations(); let insertIndex = importDeclarations.length; // Default to appending for (let i = importDeclarations.length - 1; i >= 0; i--) { const existingImport = importDeclarations[i].getModuleSpecifier().getLiteralText(); // Insert the new import before the first one that is lexicographically greater if (existingImport.localeCompare(relativePath) > 0) { insertIndex = i; } else { break; } } // Insert the new import at the correct index sourceFile.insertImportDeclaration(insertIndex, { moduleSpecifier: relativePath, namedImports: [{ name: "MethodMetadata" }], }); console.log(`Added import for MetadataType in file: ${filePath} with relative path: ${relativePath}`); } // Write the updated file back to disk sourceFile.saveSync(); } /** * Add decorators @MetadataMethod() to public non-static methods */ private addDecorators(sourceFile: any, classDeclaration: ClassDeclaration): boolean { let updated = false; classDeclaration.getMethods().forEach((method) => { if (method.hasModifier(SyntaxKind.PublicKeyword) && !method.hasModifier(SyntaxKind.StaticKeyword)) { // Check if the decorator already exists const hasDecorator = method.getDecorators().some(decorator => decorator.getName() === "MethodMetadata"); // If method doesn't have decorator and the method doesn't start with '_' (assuming it's internal method) if (!hasDecorator && !method.getName().startsWith('_')) { method.addDecorator({ name: "MethodMetadata", arguments: [], }); method.formatText(); updated = true; } } }); sourceFile.saveSync(); return updated; } } /** * Class to parse and update the metadata of enum-like classes. * These are classes which are similar to enums, but map to classes rather than * primitive types. */ export class EnumLikeUpdater extends MetadataUpdater { constructor(dir: string) { super(dir); } /** * Parse the repository for any enum-like classes and generate a JSON blueprint. */ public execute(): void { const enumlikeBlueprint: Record<string, Record<string, string[]>> = {}; // Retrieve enum-like classes this.project.getSourceFiles().forEach((sourceFile) => { const sourceFileName: string = sourceFile.getFilePath().split("/aws-cdk/")[1] let fileBlueprint: Record<string, string[]> = {}; sourceFile.forEachChild((node) => { if (node instanceof ClassDeclaration) { const className = node.getName(); if (className) { node.forEachChild((classField) => { if (classField instanceof PropertyDeclaration) { // enum-likes have `public static readonly` attributes that map to either new or call expressions const initializerKind = classField.getInitializer()?.getKind(); if (initializerKind && classField.getText().startsWith("public static readonly") && (initializerKind === SyntaxKind.NewExpression || initializerKind === SyntaxKind.CallExpression || initializerKind === SyntaxKind.PropertyAccessExpression) ) { // This is an enum-like; add to blueprint const enumlikeName = classField.getName(); if (!fileBlueprint[className]) { fileBlueprint[className] = []; } fileBlueprint[className].push(enumlikeName); } } }); if (Object.values(fileBlueprint).length > 0) { enumlikeBlueprint[sourceFileName] = fileBlueprint; } } } }); }); // Generate the file content const outputPath = path.resolve( __dirname, "../../../../packages/aws-cdk-lib/core/lib/analytics-data-source/enums/module-enumlikes.json" ); // Write the generated file this.writeFileContent(outputPath, enumlikeBlueprint); console.log(`Metadata file written to: ${outputPath}`); } }