tools/@aws-cdk/enum-updater/lib/static-enum-mapping-updater.ts (365 lines of code) (raw):

import * as fs from 'fs'; import * as path from 'path'; import axios from 'axios'; import * as tmp from 'tmp'; import * as extract from 'extract-zip'; const ENUMS_URL = "https://raw.githubusercontent.com/aws/aws-cdk/main/packages/aws-cdk-lib/core/lib/analytics-data-source/enums/module-enums.json"; const ENUM_LIKE_CLASSES_URL = "https://raw.githubusercontent.com/aws/aws-cdk/main/packages/aws-cdk-lib/core/lib/analytics-data-source/enums/module-enumlikes.json"; const CFN_LINT_URL = "https://github.com/aws-cloudformation/cfn-lint/archive/refs/heads/main.zip" const MODULE_MAPPING = path.join(__dirname, "module-mapping.json"); const STATIC_MAPPING_FILE_NAME = "static-enum-mapping.json"; const PARSED_CDK_ENUMS_FILE_NAME = "cdk-enums.json"; const EXCLUDE_FILE = "exclude-values.json"; export const PARSED_SDK_ENUMS_FILE_NAME = "sdk-enums.json"; export const STATIC_MAPPING = path.join(__dirname, STATIC_MAPPING_FILE_NAME); export const CDK_ENUMS = path.join(__dirname, PARSED_CDK_ENUMS_FILE_NAME); export const SDK_ENUMS = path.join(__dirname, PARSED_SDK_ENUMS_FILE_NAME); export const EXCLUDE_ENUMS = path.join(__dirname, EXCLUDE_FILE); // Set up cleanup handlers for process termination tmp.setGracefulCleanup(); interface DownloadResult { path: string | null; cleanup: () => Promise<void>; } export interface SdkEnums { [service: string]: { [enumName: string]: (string | number)[]; }; } function extractEnums(schema: Record<string, any>, enums: { [enumName: string]: (string | number)[] }) { // Helper function to process a property and its potential enum values function processProperty(propertyName: string, property: any) { if (property.enum) { enums[propertyName] = property.enum; } else if (property.items?.enum) { enums[propertyName] = property.items.enum; } // Process nested properties if (property.properties) { for (const [nestedName, nestedProp] of Object.entries(property.properties)) { processProperty(nestedName, nestedProp); } } } // Process main properties for (const [propertyName, property] of Object.entries(schema.properties)) { processProperty(propertyName, property); } // Process definitions if (schema.definitions) { for (const [definitionName, definition] of Object.entries(schema.definitions)) { processProperty(definitionName, definition); } } } interface EnumValue { path: string; enumLike: boolean; values: (string | number)[]; } export interface CdkEnums { [module: string]: { [enumName: string]: EnumValue; }; } interface MatchResult { service: string | null; enumName: string | null; matchPercentage: number; } export interface StaticMappingEntry { cdk_path: string; sdk_service: string; sdk_enum_name: string; match_percentage: number; } export interface StaticMapping { [module: string]: { [enumName: string]: StaticMappingEntry; }; } interface UnmatchedEnum { cdk_path: string; } interface UnmatchedEnums { [module: string]: { [enumName: string]: UnmatchedEnum; }; } /** * Downloads a file from a given GitHub raw URL and saves it to a temporary location. * * @param url - The GitHub raw file URL to download. * @returns A `DownloadResult` containing the path and cleanup function. */ export async function downloadGithubRawFile(url: string): Promise<DownloadResult> { try { console.log(`Downloading file from: ${url}`); const response = await axios.get(url); if (response.status !== 200) { throw new Error(`Failed to download file. Status code: ${response.status}`); } // Create temporary file and write content const tmpFile = tmp.fileSync(); fs.writeFileSync(tmpFile.name, JSON.stringify(response.data, null, 2), 'utf8'); console.log(`File successfully downloaded to: ${tmpFile.name}`); return { path: tmpFile.name, cleanup: async () => tmpFile.removeCallback(), }; } catch (error) { console.error(`Error downloading file from ${url}:`, error); return { path: null, cleanup: async () => {} }; } } /** * Downloads the CFN Lint Schema ZIP file, extracts the schema directory in us-east-1, * and stores it in a temporary location. * * @param url - The URL to the CFN Lint Schema ZIP archive. * @returns A `DownloadResult` containing: * - `path`: The temporary directory where schema is extracted. * - `cleanup`: A function to remove temporary files after use. */ export async function downloadAwsSdkModels(url: string): Promise<DownloadResult> { console.log(`Downloading CFN Lint schemas from: ${url}`); // Temporary storage setup const tmpDir = tmp.dirSync({ unsafeCleanup: true }); const zipFile = tmp.fileSync({ postfix: '.zip' }); const modelsDir = tmp.dirSync({ unsafeCleanup: true }); try { // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); if (response.status !== 200) { throw new Error(`Failed to download ZIP. Status code: ${response.status}`); } console.log(`Download successful. Writing to temporary file: ${zipFile.name}`); fs.writeFileSync(zipFile.name, response.data); // Extract ZIP contents console.log("Extracting ZIP file..."); await extract(zipFile.name, { dir: tmpDir.name }); // Locate and copy `aws-models` directory const sourceDir = path.join(tmpDir.name, 'cfn-lint-main/src/cfnlint/data/schemas/providers/us_east_1'); if (!fs.existsSync(sourceDir)) { throw new Error("aws-models directory not found in extracted contents."); } fs.cpSync(sourceDir, modelsDir.name, { recursive: true }); console.log(`Extracted aws-models to: ${modelsDir.name}`); return { path: modelsDir.name, cleanup: async () => cleanupTempFiles(zipFile, tmpDir, modelsDir), }; } catch (error) { console.error("Error downloading or extracting repository:", error); return { path: null, cleanup: async () => cleanupTempFiles(zipFile, tmpDir, modelsDir) }; } } /** * Cleans up temporary files and directories. * * @param zipFile - Temporary ZIP file reference. * @param tmpDir - Temporary extraction directory. * @param modelsDir - Extracted `aws-models` directory. */ async function cleanupTempFiles(zipFile: tmp.FileResult, tmpDir: tmp.DirResult, modelsDir: tmp.DirResult) { try { zipFile.removeCallback(); tmpDir.removeCallback(); modelsDir.removeCallback(); console.log("Temporary files cleaned up successfully."); } catch (error) { console.warn("Failed to clean up temporary files:", error); } } export function extractServiceName(fileName: string) { return fileName.split('-')[1]; } /** * Parses AWS SDK model JSON files to extract and store enum definitions. * * @param sdkModelsPath - The path to the directory containing AWS SDK model JSON files. * @returns A promise that resolves once the enums are parsed and saved. */ export async function parseAwsSdkEnums(sdkModelsPath: string): Promise<void> { const sdkEnums: SdkEnums = {}; try { const jsonFiles = getJsonFiles(sdkModelsPath); for (const file of jsonFiles) { try { if (file == 'module.json') { continue; } const jsonData = readJsonFile(path.join(sdkModelsPath, file)); const service = extractServiceName(file); const enumMap = sdkEnums[service] ?? {}; // Extract enums extractEnums(jsonData, enumMap); sdkEnums[service] = enumMap; } catch (error) { console.warn(`Error processing file ${file}:`, error); } } saveParsedEnums(sdkEnums); } catch (error) { console.error('Error parsing AWS SDK enums:', error); throw error; } } /** * Retrieves all JSON files from a directory. */ function getJsonFiles(directory: string): string[] { return fs.readdirSync(directory).filter(file => file.endsWith('.json')); } /** * Reads and parses a JSON file. */ function readJsonFile(filePath: string): Record<string, any> { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } /** * Saves the parsed enums to a JSON file. */ function saveParsedEnums(sdkEnums: SdkEnums): void { const outputPath = `lib/${PARSED_SDK_ENUMS_FILE_NAME}`; fs.writeFileSync(outputPath, JSON.stringify(sdkEnums, null, 2)); console.log(`Wrote SDK enums to ${outputPath}`); } /** * Extracts the AWS module name from a given file path. * * @param {string} path - The file path from which to extract the module name. * @returns {string | null} The extracted module name, or `null` if extraction fails. */ export function extractModuleName(path: string): string | null { try { const moduleName = path.split("/")[3]; if (!moduleName) return null; let processedName = moduleName; if (processedName.startsWith("aws-")) { processedName = processedName.substring(4); } if (processedName.endsWith("-alpha")) { processedName = processedName.slice(0, -6); } return processedName; } catch (error) { return null; } } /** * Processes the CDK enum files and extracts enum definitions. * * @param {string} enumsFilePath - The file path for standard CDK enums. * @param {string} enumsLikeFilePath - The file path for "enum-like" values. * @returns {Promise<void>} */ async function processCdkEnums(enumsFilePath: string, enumsLikeFilePath: string): Promise<void> { const processedData: CdkEnums = {}; let totalEnums = 0; try { // Process the first file (enums) await processFile(enumsFilePath, false); // Process the second file (enum-like) await processFile(enumsLikeFilePath, true); fs.writeFileSync(`lib/${PARSED_CDK_ENUMS_FILE_NAME}`, JSON.stringify(processedData, null, 2)); console.log(`Wrote CDK enums to ${PARSED_CDK_ENUMS_FILE_NAME}`); console.log(`Total CDK enums parsed: ${totalEnums}`); } catch (error) { console.error('Error processing CDK enums:', error); throw error; } async function processFile(filePath: string, isEnumLike: boolean) { const rawData = JSON.parse(fs.readFileSync(filePath, 'utf8')); for (const [path, enums] of Object.entries(rawData)) { const moduleName = extractModuleName(path); if (!moduleName) continue; if (!processedData[moduleName]) { processedData[moduleName] = {}; } for (const [enumName, values] of Object.entries(enums as Record<string, (string | number)[]>)) { processedData[moduleName][enumName] = { path, enumLike: isEnumLike, values: values }; totalEnums++; } } } } /** * Normalizes an enum value by converting it to uppercase and removing non-alphanumeric characters. * * @param {string | number} value - The enum value to normalize. * @returns {string} The normalized string representation of the value. */ export function normalizeValue(value: string | number): string { const strValue = String(value); if (strValue.match(/^\d+$/)) { return strValue; } return strValue.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); } /** * Normalizes a list of enum values and converts them into a unique set. * * @param {(string | number)[]} values - The list of enum values. * @returns {Set<string>} A set of normalized enum values. */ export function normalizeEnumValues(values: (string | number)[]): Set<string> { return new Set(values.map(normalizeValue)); } /** * Calculates the percentage of CDK enum values that match SDK enum values. * * @param {Set<string>} cdkValues - The set of CDK enum values. * @param {Set<string>} sdkValues - The set of SDK enum values. * @returns {number} A percentage representing the proportion of matching values. */ function calculateValueMatchPercentage(cdkValues: Set<string>, sdkValues: Set<string>): number { if (cdkValues.size === 0) return 0; const matchingValues = new Set([...cdkValues].filter(x => sdkValues.has(x))); return matchingValues.size / cdkValues.size; } /** * Finds the best-matching AWS SDK enum for a given CDK enum. * * @param {string} cdkEnumName - The name of the CDK enum. * @param {(string | number)[]} cdkValues - The values of the CDK enum. * @param {string[]} sdkServices - The list of AWS SDK services to search in. * @param {SdkEnums} sdkEnums - The AWS SDK enums database. * @returns {MatchResult} An object containing the best match details: * - `service`: The AWS SDK service containing the best-matching enum. * - `enumName`: The name of the best-matching SDK enum. * - `matchPercentage`: The percentage of matching values. */ export function findMatchingEnum( cdkEnumName: string, cdkValues: (string | number)[], sdkServices: string[], sdkEnums: SdkEnums ): MatchResult { let bestMatch: MatchResult = { service: null, enumName: null, matchPercentage: 0 }; const normalizedCdkValues = normalizeEnumValues(cdkValues); for (const service of sdkServices) { if (!sdkEnums[service]) continue; for (const [sdkEnumName, sdkValues] of Object.entries(sdkEnums[service])) { // Try exact name match first if (cdkEnumName.toLowerCase() === sdkEnumName.toLowerCase()) { const normalizedSdkValues = normalizeEnumValues(sdkValues); if (isValidMatch(normalizedCdkValues, normalizedSdkValues)) { return { service, enumName: sdkEnumName, matchPercentage: calculateValueMatchPercentage(normalizedCdkValues, normalizedSdkValues) }; } } const normalizedSdkValues = normalizeEnumValues(sdkValues); if (isValidMatch(normalizedCdkValues, normalizedSdkValues)) { const matchPercentage = calculateValueMatchPercentage(normalizedCdkValues, normalizedSdkValues); if (matchPercentage > bestMatch.matchPercentage) { bestMatch = { service, enumName: sdkEnumName, matchPercentage }; } } } } return bestMatch; } function isValidMatch(cdkValues: Set<string>, sdkValues: Set<string>): boolean { // Check if all CDK values are present in SDK values const isSubset = [...cdkValues].every(value => sdkValues.has(value)); const matchPercentage = calculateValueMatchPercentage(cdkValues, sdkValues); return isSubset && matchPercentage >= 0.5; } /** * Generates a static mapping between CDK enums and AWS SDK enums. * * @param {CdkEnums} cdkEnums - The extracted CDK enums. * @param {SdkEnums} sdkEnums - The extracted AWS SDK enums. * @param {Record<string, string[]>} manualMappings - The manually defined service mappings. * @returns {Promise<void>} */ export async function generateAndSaveStaticMapping( cdkEnums: CdkEnums, sdkEnums: SdkEnums, manualMappings: Record<string, string[]>, ): Promise<void> { const staticMapping: StaticMapping = {}; const unmatchedEnums: UnmatchedEnums = {}; for (const [module, enums] of Object.entries(cdkEnums)) { if (!manualMappings[module]) { // Add to unmatched if no SDK mapping exists unmatchedEnums[module] = Object.fromEntries( Object.entries(enums).map(([enumName, enumData]) => [ enumName, { cdk_path: enumData.path } ]) ); continue; } const sdkServices = manualMappings[module]; for (const [enumName, enumData] of Object.entries(enums)) { const match = findMatchingEnum( enumName, enumData.values, sdkServices, sdkEnums ); if (match.service && match.enumName) { if (!staticMapping[module]) { staticMapping[module] = {}; } staticMapping[module][enumName] = { cdk_path: enumData.path, sdk_service: match.service, sdk_enum_name: match.enumName, match_percentage: match.matchPercentage }; } else { if (!unmatchedEnums[module]) { unmatchedEnums[module] = {}; } unmatchedEnums[module][enumName] = { cdk_path: enumData.path }; } } } // Create temporary files const staticMappingTmp = tmp.fileSync({ postfix: '.json' }); const unmatchedEnumsTmp = tmp.fileSync({ postfix: '.json' }); // Write to temporary files fs.writeFileSync(staticMappingTmp.name, JSON.stringify(staticMapping, null, 2)); fs.writeFileSync(unmatchedEnumsTmp.name, JSON.stringify(unmatchedEnums, null, 2)); fs.writeFileSync(`lib/${STATIC_MAPPING_FILE_NAME}`, JSON.stringify(staticMapping, null, 2)); fs.writeFileSync('lib/unmatched-enums.json', JSON.stringify(unmatchedEnums, null, 2)); console.log(`Total matched enums: ${Object.values(staticMapping).reduce((sum, moduleEnums) => sum + Object.keys(moduleEnums).length, 0)}`); } /** * Entry point method for executing the enum update process. */ export async function entryMethod(): Promise<void> { try { const cfnLintDownload = await downloadAwsSdkModels(CFN_LINT_URL); const downloadedCdkEnumsPath = await downloadGithubRawFile(ENUMS_URL); const downloadedCdkEnumsLikePath = await downloadGithubRawFile(ENUM_LIKE_CLASSES_URL); if (!cfnLintDownload.path || !downloadedCdkEnumsPath.path || !downloadedCdkEnumsLikePath.path) { console.error("Error: Missing required files."); return; } console.log("CDK enums downloaded successfully."); await processCdkEnums(downloadedCdkEnumsPath.path, downloadedCdkEnumsLikePath.path); console.log("CFN Lint Schema downloaded successfully."); await parseAwsSdkEnums(cfnLintDownload.path); // Read the files const cdkEnums: CdkEnums = JSON.parse(fs.readFileSync(CDK_ENUMS, 'utf8')); const sdkEnums: SdkEnums = JSON.parse(fs.readFileSync(SDK_ENUMS, 'utf8')); const manualMappings: Record<string, string[]> = JSON.parse(fs.readFileSync(MODULE_MAPPING, 'utf8')); // Generate and save static mapping await generateAndSaveStaticMapping(cdkEnums, sdkEnums, manualMappings); console.log("Static mapping and missing values analysis completed."); } catch (error) { console.error('Error in enum update process:', error); throw error; } }