tools/@aws-cdk/enum-updater/lib/missing-enum-updater.ts (336 lines of code) (raw):
import { IndentationText, Project, PropertyDeclaration, QuoteKind, Scope, SyntaxKind } from "ts-morph";
import * as path from "path";
import * as fs from "fs";
import * as tmp from 'tmp';
import { CDK_ENUMS, CdkEnums, normalizeEnumValues, normalizeValue, SDK_ENUMS, SdkEnums, STATIC_MAPPING, StaticMapping } from "./static-enum-mapping-updater";
const DIRECTORIES_TO_SKIP = [
"node_modules",
"dist",
"build",
"decdk",
"awslint",
"test",
];
const EXCLUDE_FILE = "exclude-values.json";
export const EXCLUDE_ENUMS = path.join(__dirname, EXCLUDE_FILE);
interface MissingValuesEntry {
cdk_path: string;
missing_values: (string | number)[];
}
interface MissingValues {
[module: string]: {
[enumName: string]: MissingValuesEntry;
};
}
/**
* 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 MissingEnumsUpdater {
private CUSTOM_PARAM_MAPPINGS: { [cdkModule: string]: {[cdkEnumLike: string]: any}} = {
"module": {
"EnumLike": (param1: string) => {
return `${param1}, ${param1.toUpperCase()}`
}
}
}
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.");
}
/**
* 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;
}
/**
* Identifies missing enum values by comparing CDK enums with AWS SDK enums based on a static mapping.
*/
private async findMissingValues(
staticMapping: StaticMapping,
cdkEnums: CdkEnums,
sdkEnums: SdkEnums,
exclusions: Record<string, any>
): Promise<MissingValues> {
const missingValues: MissingValues = {};
for (const [module, enums] of Object.entries(staticMapping)) {
for (const [enumName, mapping] of Object.entries(enums)) {
const cdkValues = cdkEnums[module][enumName].values;
const sdkValues = sdkEnums[mapping.sdk_service][mapping.sdk_enum_name];
let exclusion = new Set();
if (exclusions[module] && exclusions[module][enumName]) {
const exclusionDict = exclusions[module][enumName];
if (!exclusionDict["values"]) {
continue;
}
exclusion = normalizeEnumValues(exclusionDict["values"]);
}
// Get normalized sets of values
const normalizedCdkValues = normalizeEnumValues(cdkValues);
const normalizedSdkValues = normalizeEnumValues(sdkValues);
// Find missing values using normalized comparison
const missingNormalized = [...normalizedSdkValues].filter(sdkValue =>
!normalizedCdkValues.has(sdkValue) && !exclusion.has(sdkValue)
);
if (missingNormalized.length > 0) {
if (!missingValues[module]) {
missingValues[module] = {};
}
// Get original SDK values that correspond to missing normalized values
const missingOriginal = sdkValues.filter(value =>
missingNormalized.includes(normalizeValue(value))
);
missingValues[module][enumName] = {
cdk_path: cdkEnums[module][enumName].path,
missing_values: missingOriginal
};
}
}
}
const totalEnumsWithMissing = Object.keys(missingValues).reduce((sum, module) =>
sum + Object.keys(missingValues[module]).length, 0);
const totalMissingValues = Object.keys(missingValues).reduce((sum, module) =>
sum + Object.keys(missingValues[module]).reduce((moduleSum, enumName) =>
moduleSum + missingValues[module][enumName].missing_values.length, 0), 0);
console.log(`Enums with missing values: ${totalEnumsWithMissing}`);
console.log(`Total missing values found: ${totalMissingValues}`);
return missingValues;
}
/**
* Saves missing enum values to a temporary JSON file.
*/
private async saveMissingValues(
staticMapping: StaticMapping,
cdkEnums: CdkEnums,
sdkEnums: SdkEnums,
exclusions: Record<string, any>
): Promise<string> {
try {
const missingValues = await this.findMissingValues(staticMapping, cdkEnums, sdkEnums, exclusions);
const tmpFile = tmp.fileSync({ postfix: '.json' });
fs.writeFileSync(tmpFile.name, JSON.stringify(missingValues, null, 2));
return tmpFile.name;
} catch (error) {
console.error('Error saving missing values:', error);
throw error;
}
}
/**
* Analyzes missing enum values between CDK and SDK by loading mappings and processing them.
*/
private async analyzeMissingEnumValues(): Promise<string> {
try {
const staticMapping: StaticMapping = JSON.parse(fs.readFileSync(STATIC_MAPPING, 'utf8'));
const cdkEnums: CdkEnums = JSON.parse(fs.readFileSync(CDK_ENUMS, 'utf8'));
const sdkEnums: SdkEnums = JSON.parse(fs.readFileSync(SDK_ENUMS, 'utf8'));
const exclusions: Record<string, any> = JSON.parse(fs.readFileSync(EXCLUDE_ENUMS, 'utf8'));
const missingValuesPath = await this.saveMissingValues(staticMapping, cdkEnums, sdkEnums, exclusions);
const totalMappings = Object.values(staticMapping)
.reduce((sum, moduleEnums) => sum + Object.keys(moduleEnums).length, 0);
console.log("\nAnalysis Statistics:");
console.log(`Total mappings analyzed: ${totalMappings}`);
console.log("Missing values analysis completed.");
return missingValuesPath;
} catch (error) {
console.error('Error analyzing missing enum values:', error);
throw error;
}
}
/**
* Retrieve the generated list of enum-like classes and the missing values,
* and update the source files with any missing values.
*/
public updateEnumValues(missingValuesPath: string): void {
// Get list of enum-likes and missing enum-likes
const parsedEnums = this.getParsedEnumValues();
const missingEnums = this.getMissingEnumValues(missingValuesPath);
// Update the parsed_cdk_enums.json file
Object.keys(missingEnums).forEach((cdkModule) => {
Object.keys(missingEnums[cdkModule]).forEach((enumKey) => {
if (parsedEnums[cdkModule]?.[enumKey]) {
this.updateEnum(enumKey, missingEnums[cdkModule][enumKey])
}
});
});
}
/**
* Retrieve the parsed enum values from the generated list
* @returns A dictionary containing the parsed_cdk_enums.json file with only regular enums
*/
private getParsedEnumValues(): any {
// Get file contents
const fileContent = fs.readFileSync(CDK_ENUMS, 'utf8');
var jsonData = JSON.parse(fileContent);
// Remove anything that is enum-like
Object.keys(jsonData).forEach((cdkModule) => {
Object.keys(jsonData[cdkModule]).forEach((enumKey) => {
if (jsonData[cdkModule][enumKey].enumLike) {
delete jsonData[cdkModule][enumKey];
}
});
});
// Clean up empty modules
Object.keys(jsonData).forEach((cdkModule) => {
if (Object.keys(jsonData[cdkModule]).length === 0) {
delete jsonData[cdkModule];
}
});
return jsonData;
}
/**
* Retrieve the list of missing values for regular enum values
* @returns A dictionary containing the missing-values.json file with only regular enums with missing values
*/
private getMissingEnumValues(missingValuesPath: string): any {
// Get file contents
const fileContent = fs.readFileSync(missingValuesPath, 'utf8');
var jsonData = JSON.parse(fileContent);
const parsedEnums = this.getParsedEnumValues();
// Remove anything that isn't in the parsed enum-likes (regular enums)
Object.keys(jsonData).forEach((cdkModule) => {
Object.keys(jsonData[cdkModule]).forEach((enumKey) => {
if (!parsedEnums[cdkModule]?.[enumKey]) {
delete jsonData[cdkModule][enumKey];
}
});
});
// Clean up empty modules
Object.keys(jsonData).forEach((cdkModule) => {
if (Object.keys(jsonData[cdkModule]).length === 0) {
delete jsonData[cdkModule];
}
});
return jsonData
}
/**
* Update a single enum value
* @param enumName The enum name
* @param missingValue The dictionary from the `missing-values.json` file
* containing the cdk_path and missing_values for the enum
*/
private updateEnum(enumName: string, missingValue: any): void {
// Get the right source file to modify
let sourceFile = this.project.getSourceFile(path.resolve(__dirname, '../../../..', this.removeAwsCdkPrefix(missingValue['cdk_path'])));
if (!sourceFile) {
throw new Error(`Source file not found: ${missingValue['cdk_path']}`);
}
// Get the class declaration
const enumDeclaration = sourceFile.getEnum(enumName)
if (!enumDeclaration) {
throw new Error(`Enum declaration not found: ${enumName}`);
}
const newEnumValues = missingValue['missing_values'];
// First get the full text
let enumText = enumDeclaration.getFullText();
// If the text starts with empty lines before the docstring (which starts with /**),
// remove only those empty lines
if (enumText.startsWith('\n') && enumText.includes('/**')) {
const docstringStart = enumText.indexOf('/**');
const leadingText = enumText.substring(0, docstringStart);
const restOfText = enumText.substring(docstringStart);
enumText = leadingText.replace(/^\n+/, '') + restOfText;
}
// Get just the enum body (everything between the curly braces)
const enumBodyStart = enumText.indexOf('{') + 1;
const enumBodyEnd = enumText.lastIndexOf('}');
const enumBody = enumText.substring(enumBodyStart, enumBodyEnd);
// Check for double line breaks only in the enum body
const hasDoubleLineBreaks = enumBody.includes('\n\n');
// Find the position to insert new members (just before the closing brace)
const insertPosition = enumText.lastIndexOf('}');
// Prepare the text to insert - only add initial newline if enum uses double line breaks
let textToInsert = hasDoubleLineBreaks ? '\n' : '';
newEnumValues.forEach((enumVal: string, index: number) => {
// Make sure enumValue is a string
const enumValue = enumVal.toString();
const enumConstantName = enumValue.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/_+$/, '');
textToInsert += ` /**\n * PLACEHOLDER_COMMENT_TO_BE_FILLED_OUT\n */\n`;
textToInsert += ` ${enumConstantName} = '${enumValue}'`;
// Add a comma and appropriate newlines after each member
textToInsert += ',';
// Add newlines after each member except the last one
if (index < newEnumValues.length - 1) {
textToInsert += hasDoubleLineBreaks ? '\n\n' : '\n';
}
});
// Add final newline before the closing brace
textToInsert += '\n';
// Insert the new text
enumText = enumText.slice(0, insertPosition) + textToInsert + enumText.slice(insertPosition);
// Set the full text of the enum
enumDeclaration.replaceWithText(enumText);
// Write the updated file back to disk
sourceFile.saveSync();
}
/**
* Retrieve the generated list of enum-like classes and the missing values,
* and update the source files with any missing values.
*/
public updateEnumLikeValues(missingValuesPath: string): void {
// Get list of enum-likes and missing enum-likes
const parsedEnumLikes = this.getParsedEnumLikeValues();
const missingEnumLikes = this.getMissingEnumLikeValues(missingValuesPath);
// Update the parsed_cdk_enums.json file
Object.keys(missingEnumLikes).forEach((cdkModule) => {
Object.keys(missingEnumLikes[cdkModule]).forEach((enumKey) => {
if (parsedEnumLikes[cdkModule]?.[enumKey]) {
this.updateEnumLike(cdkModule, enumKey, missingEnumLikes[cdkModule][enumKey])
}
});
});
}
/**
* Retrieve the parsed enum-like values from the generated list
* @returns A dictionary containing the parsed_cdk_enums.json file with only enum-likes
*/
private getParsedEnumLikeValues(): any {
// Get file contents
const fileContent = fs.readFileSync(CDK_ENUMS, 'utf8');
var jsonData = JSON.parse(fileContent);
// Remove anything that isn't enum-like
Object.keys(jsonData).forEach((cdkModule) => {
Object.keys(jsonData[cdkModule]).forEach((enumKey) => {
if (!jsonData[cdkModule][enumKey].enumLike) {
delete jsonData[cdkModule][enumKey];
}
});
});
// Clean up empty modules
Object.keys(jsonData).forEach((cdkModule) => {
if (Object.keys(jsonData[cdkModule]).length === 0) {
delete jsonData[cdkModule];
}
});
return jsonData;
}
/**
* Retrieve the list of missing values for enum-like values
* @returns A dictionary containing the missing-values.json file with only enum-likes with missing values
*/
private getMissingEnumLikeValues(missingValuesPath: string): any {
// Get file contents
const fileContent = fs.readFileSync(missingValuesPath, 'utf8');
var jsonData = JSON.parse(fileContent);
const parsedEnumLikes = this.getParsedEnumLikeValues();
// Remove anything that isn't in the parsed enum-likes (regular enums)
Object.keys(jsonData).forEach((cdkModule) => {
Object.keys(jsonData[cdkModule]).forEach((enumKey) => {
if (!parsedEnumLikes[cdkModule]?.[enumKey]) {
delete jsonData[cdkModule][enumKey];
}
});
});
// Clean up empty modules
Object.keys(jsonData).forEach((cdkModule) => {
if (Object.keys(jsonData[cdkModule]).length === 0) {
delete jsonData[cdkModule];
}
});
return jsonData
}
/**
* Update a single enum-like value
* @param moduleName The module name that the enum-like class is in
* @param enumLikeName The enum-like class name
* @param missingValue The dictionary from the `missing-values.json` file
* containing the cdk_path and missing_values for the enum-like class
*/
private updateEnumLike(moduleName: string, enumLikeName: string, missingValue: any): void {
// Get the right source file to modify
let sourceFile = this.project.getSourceFile(path.resolve(__dirname, '../../../..', this.removeAwsCdkPrefix(missingValue['cdk_path'])));
if (!sourceFile) {
throw new Error(`Source file not found: ${missingValue['cdk_path']}`);
}
// Get the class declaration
const classDeclaration = sourceFile.getClass(enumLikeName);
if (!classDeclaration) {
throw new Error(`Class declaration not found: ${enumLikeName}`);
}
// Determine the type of enum-like
let initializerStatement = "";
let lastEnumLikeOrderPos = 0;
classDeclaration.forEachChild((classField) => {
if (classField instanceof PropertyDeclaration) {
const initializerKind = classField.getInitializer()?.getKind();
if (initializerKind && classField.getText().startsWith("public static readonly") &&
classField.getName() === classField.getName().toUpperCase()
) {
lastEnumLikeOrderPos++;
if (initializerKind === SyntaxKind.NewExpression) { // X = new Class(...)
initializerStatement = `new ${enumLikeName}(`;
} else if (initializerKind === SyntaxKind.CallExpression) { // X = Class.method(...)
initializerStatement = `${enumLikeName}.${classField.getInitializerIfKind(SyntaxKind.CallExpression)?.getExpressionIfKind(SyntaxKind.PropertyAccessExpression)?.getName()}(`;
} else {
console.log(`Skipping ${enumLikeName} as it does not fit currently-defined declaration patterns...`)
return;
}
// If we have custom logic, use that
if (this.CUSTOM_PARAM_MAPPINGS[moduleName] && this.CUSTOM_PARAM_MAPPINGS[moduleName][enumLikeName]) {
initializerStatement += `${this.CUSTOM_PARAM_MAPPINGS[moduleName][enumLikeName](classField.getName())})`;
} else {
// Otherwise, assume it's a single string
const args = initializerKind === SyntaxKind.NewExpression ? classField.getInitializerIfKind(SyntaxKind.NewExpression)?.getArguments() : classField.getInitializerIfKind(SyntaxKind.CallExpression)?.getArguments();
if (!args || args.length !== 1) {
console.log(`Skipping ${enumLikeName} as it does not fit currently-defined parameter patterns...`);
return;
}
initializerStatement += "'${VAL}')"
}
}
}
});
// Add the missing enum-like values
for (const enumLikeVal of missingValue['missing_values']) {
const newProperty = classDeclaration.addProperty({
name: enumLikeVal.toUpperCase().replaceAll('-', '_').replaceAll('.', '_'),
scope: Scope.Public,
isStatic: true,
isReadonly: true,
initializer: initializerStatement.replace("${VAL}", enumLikeVal),
})
newProperty.setOrder(lastEnumLikeOrderPos); // Place at the end of the enum-likes
newProperty.addJsDoc("\nPLACEHOLDER_COMMENT_TO_BE_FILLED_OUT"); // Add temp docstring comment
}
// Write the updated file back to disk
sourceFile.saveSync();
}
/**
* Removes the "aws-cdk/" prefix from the given string if it exists.
* @param input The string to process.
* @returns The string without the "aws-cdk/" prefix.
*/
private removeAwsCdkPrefix(input: string): string {
return input.startsWith("aws-cdk/") ? input.slice(8) : input;
}
public async execute() {
const missingValuesPath = await this.analyzeMissingEnumValues()
this.updateEnumLikeValues(missingValuesPath);
this.updateEnumValues(missingValuesPath);
}
}