Skip to content

Commit

Permalink
feat: add new builders for metadata migration check
Browse files Browse the repository at this point in the history
  • Loading branch information
cpourcel committed Apr 29, 2024
1 parent 3f0d5a9 commit 3a39949
Show file tree
Hide file tree
Showing 28 changed files with 775 additions and 0 deletions.
5 changes: 5 additions & 0 deletions packages/@o3r/components/builders.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"implementation": "./builders/component-extractor/",
"schema": "./builders/component-extractor/schema.json",
"description": "Extract the component metadata (configuration and class) from an Otter project"
},
"check-config-migration-metadata": {
"implementation": "./builders/metadata-check/",
"schema": "./builders/metadata-check/schema.json",
"description": "Check for component metadata breaking changes"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ComponentConfigOutput } from '@o3r/components';
import { MetadataComparator } from '@o3r/extractors';

/**
* Interface describing a config migration element
*/
export interface MigrationConfigData {
libraryName: string;
configName: string;
propertyName?: string;
}

/**
* Comparator used to compare one version of config metadata with another
*/
export class ConfigMetadataComparator implements MetadataComparator<ComponentConfigOutput> {
/**
* Returns an array of config metadata from a metadata file.
* To be easily parseable, the properties will be split in separate items of the array.
* e.g. : [{ library: '@o3r/demo', properties: [{name : 'property1', type: 'string'}, {name : 'property2', type: 'number'}] }]
* will become :
* [{ library: '@o3r/demo', properties: [{name : 'property1', type: 'string'}] }, { library: '@o3r/demo', properties: [{name : 'property2', type: 'number'}] }]
*
* @param content Content of a migration metadata files
*/
public getArray(content: ComponentConfigOutput[]): ComponentConfigOutput[] {
return content.reduce((acc, config) => {
if (config.properties.length) {
const propertiesConfigs = config.properties.map((property) => {
return {
...config,
properties: [property]
};
});
acc.push(...propertiesConfigs);
} else {
acc.push(config);
}
return acc;
}, [] as ComponentConfigOutput[]);
}

/**
* @inheritdoc
*/
public getName(config: ComponentConfigOutput): string {
return `${config.library}#${config.name}` + config.properties.length ? `-${config.properties[0].name}` : '';
}

/**
* @inheritdoc
*/
public isSame(config1: ComponentConfigOutput, config2: ComponentConfigOutput): boolean {
return config1.name === config2.name && config1.library === config2.library
&& config1.properties[0]?.name === config2.properties[0]?.name;
}

/**
* @inheritdoc
*/
public isMigrationDataMatch(config: ComponentConfigOutput, migrationData: MigrationConfigData): boolean {
return migrationData.configName === config.name && migrationData.libraryName === config.library
&& config.properties[0]?.name === migrationData.propertyName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './config-metadata-comparison.helper';
8 changes: 8 additions & 0 deletions packages/@o3r/components/builders/metadata-check/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { checkMetadataBuilder, createBuilderWithMetricsIfInstalled } from '@o3r/extractors';
import { ConfigMetadataComparator } from './helpers/config-metadata-comparison.helper';
import type { ConfigMigrationMetadataCheckBuilderSchema } from './schema';

export default createBuilder<ConfigMigrationMetadataCheckBuilderSchema>(createBuilderWithMetricsIfInstalled((options, context): Promise<BuilderOutput> => {
return checkMetadataBuilder(options, context, new ConfigMetadataComparator());
}));
46 changes: 46 additions & 0 deletions packages/@o3r/components/builders/metadata-check/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"$id": "ConfigMigrationMetadataCheckBuilderSchema",
"title": "Check config migration metadata builder",
"description": "",
"properties": {
"migrationMetadataFolder": {
"type": "string",
"description": "Path to the file containing the migration metadata."
},
"granularity": {
"type": "string",
"description": "Granularity of the migration check.",
"default": "minor"
},
"allowBreakingChanges": {
"type": "boolean",
"description": "Are breaking changes allowed.",
"default": true
},
"packageManager": {
"type": "string",
"description": "Override of the package manager, otherwise it will be determined from the project."
},
"metadataPath": {
"type": "string",
"description": "Path of the config metadata file.",
"default": "./component.config.metadata.json"
},
"migrationMetadataName": {
"type": "string",
"description": "Migration metadata name override, if not provided will use the one with the highest version following the pattern."
},
"toVersion": {
"type": "string",
"description": "Override the version to consider as the latest one for metadata comparison. If not provided, the latest migration metadata file will be considered as the latest version number."
},
"fromVersion": {
"type": "string",
"description": "Override the previous version number to use. If not provided, it will be the latest package available from npm < toVersion and matching the granularity."
}
},
"additionalProperties": false,
"required": ["migrationMetadataFolder"]
}
5 changes: 5 additions & 0 deletions packages/@o3r/components/builders/metadata-check/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { MigrationMetadataCheckBuilderOptions } from '@o3r/extractors';

/** Migration metadata check builder schema */
export interface ConfigMigrationMetadataCheckBuilderSchema extends MigrationMetadataCheckBuilderOptions {
}
21 changes: 21 additions & 0 deletions packages/@o3r/extractors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,32 @@
"prepare:publish": "prepare-publish ./dist"
},
"peerDependencies": {
"@angular-devkit/architect": "~0.1703.0",
"@angular-devkit/core": "~17.3.0",
"@o3r/core": "workspace:^",
"@o3r/schematics": "workspace:^",
"@o3r/telemetry": "workspace:^",
"@yarnpkg/core": "*",
"@yarnpkg/fslib": "*",
"@yarnpkg/plugin-npm": "*",
"semver": "^7.5.2",
"typescript": "~5.4.2"
},
"peerDependenciesMeta": {
"@o3r/telemetry": {
"optional": true
},
"@yarnpkg/core": {
"optional": true
},
"@yarnpkg/fslib": {
"optional": true
},
"@yarnpkg/plugin-npm": {
"optional": true
},
"semver": {
"optional": true
}
},
"dependencies": {
Expand Down Expand Up @@ -70,8 +87,12 @@
"@types/inquirer": "~8.2.10",
"@types/jest": "~29.5.2",
"@types/node": "^20.0.0",
"@types/semver": "^7.3.13",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@yarnpkg/core": "^4.0.3",
"@yarnpkg/fslib": "^3.0.2",
"@yarnpkg/plugin-npm": "^3.0.1",
"cpy-cli": "^5.0.0",
"eslint": "^8.57.0",
"eslint-import-resolver-node": "^0.3.9",
Expand Down
3 changes: 3 additions & 0 deletions packages/@o3r/extractors/src/core/comparator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './metadata-comparator.interface';
export * from './metadata-comparison.helper';
export * from './metadata-files.helper';
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { JsonObject } from '@angular-devkit/core';
import type { SupportedPackageManagers } from '@o3r/schematics';

/**
* Interface of the comparator used to compare 2 different versions of the same metadata file.
*/
export interface MetadataComparator<T> {
/**
* Get an array of metadata items to parse a metadata file content.
* @param content Content of a metadata file
*/
getArray(content: any): T[];

/**
* Get a description of a metadata item.
* @param item Metadata item
*/
getName(item: T): string;

/**
* Compares 2 metadata items.
* @param item1 Metadata item
* @param item2 Metadata item to compare with
*/
isSame(item1: T, item2: T): boolean;

/**
* Returns true if a migration item matches a metadata item.
* @param metadataItem Metadata item
* @param migrationItem Migration item
*/
isMigrationDataMatch(metadataItem: T, migrationItem: any): boolean;
}

/**
* Migration item used to document a migration of a config, localization or styling metadata.
*/
export interface MigrationData<T> {
/** Metadata type */
contentType: 'CONFIG' | 'LOCALIZATION' | 'STYLING';

/** Previous metadata value */
before: T;

/** New metadata value */
after: T;
}

/**
* Generic metadata builder options
*/
export interface MigrationMetadataCheckBuilderOptions extends JsonObject {
/** Path to the folder containing the migration metadata. */
migrationMetadataFolder: string;

/** Granularity of the migration check. */
granularity: 'major' | 'minor';

/** Whether breaking changes are allowed.*/
allowBreakingChanges: boolean;

/** Override of the package manager, otherwise it will be determined from the project. */
packageManager: SupportedPackageManagers;

/** Path of the metadata file to check */
metadataPath: string;

/** Migration metadata name override, if not provided will use the one with the highest version following the pattern. */
migrationMetadataName: string;

/** Override the version to consider as the latest one for metadata comparison. If not provided, the latest migration metadata file will be considered as the latest version number. */
toVersion: string;

/** Override the previous version number to use. If not provided, it will be the latest package available from npm < toVersion and matching the granularity. */
fromVersion: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import { getPackageManagerInfo, type PackageManagerOptions, type SupportedPackageManagers, type WorkspaceSchema } from '@o3r/schematics';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { MetadataComparator, MigrationData, MigrationMetadataCheckBuilderOptions } from './metadata-comparator.interface';
import { getFilesFromRegistry, getLatestMigrationMetadataFile, getLocalMetadataFile, getVersionFromFilename, getVersionRangeFromLatestVersion } from './metadata-files.helper';

// TODO : create a bespoke type for the errors?
function checkMetadataFile(lastMetadataFile: any, newMetadataFile: any, migrationData: MigrationData<any>[], isBreakingChangeAllowed: boolean, comparator: MetadataComparator<any>): Error[] {
const errors = [] as Error[];
const newMetadataArray = comparator.getArray(newMetadataFile);
const lastMetadataArray = comparator.getArray(lastMetadataFile);
for (const lastValue of lastMetadataArray) {
const isInNewMetadata = newMetadataArray.some((newValue) => comparator.isSame(newValue, lastValue));
if (isInNewMetadata) {
if (!isBreakingChangeAllowed) {
errors.push(new Error(`Property ${comparator.getName(lastValue)} is not present in the new metadata and breaking changes are not allowed`));
break;
}

const migrationMetadataValue = migrationData.find((metadata) => comparator.isMigrationDataMatch(lastValue, metadata.after));

if (!migrationMetadataValue) {
errors.push(new Error(`Property ${comparator.getName(lastValue)} has been modified but is not documented in the migration document`));
break;
}

if (migrationMetadataValue.after) {
const isNewValueInNewMetadata = newMetadataArray.some((newValue) => comparator.isMigrationDataMatch(newValue, migrationMetadataValue.after));
if (!isNewValueInNewMetadata) {
errors.push(new Error(`Property ${comparator.getName(lastValue)} has been modified but the new property is not present in the new metadata`));
break;
}
}
}
}
return errors;
}

/**
* Gets the package manager to use to retrieve the previous package from npm.
* If the project uses npm or yarn it will be npm.
* If the project uses yarn 2+ it will be yarn.
* This is especially important because npm and yarn 1 use the authentication from the .npmrc while yarn 2+ uses the .yarnrc.
* @param options Option to determine the final package manager
*/
function getPackageManagerForRegistry(options?: PackageManagerOptions): SupportedPackageManagers | undefined {
const packageManagerInfo = getPackageManagerInfo(options);
if (!packageManagerInfo.version) {
return undefined;
}
return packageManagerInfo.name === 'yarn' && !packageManagerInfo.version.match(/^1\./) ? 'yarn' : 'npm';
}

/**
* Checks a type of metadata against a previous version of these metadata extracted from a npm package.
* Will return errors if some changes are breaking and they are not allowed, of if the changes are not documented in the file
* provided in options.
* @param options Options for the buidler
* @param context Builder context (from another builder)
* @param comparator Comparator implementation, depends on the type of metadata to check
*/
export async function checkMetadataBuilder<T>(options: MigrationMetadataCheckBuilderOptions, context: BuilderContext, comparator: MetadataComparator<T>): Promise<BuilderOutput> {
context.reportRunning();
const angularJsonPath = join(context.workspaceRoot, 'angular.json');
const angularJson = existsSync(angularJsonPath) ? JSON.parse(readFileSync(angularJsonPath, { encoding: 'utf8' }).toString()) as WorkspaceSchema : undefined;
if (!angularJson) {
context.logger.warn(`angular.json file cannot be found by @o3r/core:${context.builder.builderName} builder.
Detection of package manager runner will fallback on the one used to execute the actual command.`);
}

const packageManager = getPackageManagerForRegistry({
workspaceConfig: angularJson,
enforcedNpmManager: options.packageManager
});

if (!packageManager) {
return {
success: false,
error: 'The package manager to use could not be determined. Try to override it using the packageManager option.'
};
}

// TODO: should be an input?
const migrationFileNamePattern = /MIGRATION-(\d+\.\d+)\.json/;
const migrationFileName = options.migrationMetadataName ?? getLatestMigrationMetadataFile(options.migrationMetadataFolder, migrationFileNamePattern);

if (!migrationFileName) {
throw new Error(`No migration data could be found in ${options.migrationMetadataFolder}, expected format: ${migrationFileNamePattern}`);
}

let previousVersion = options.fromVersion;
if (!previousVersion) {
const currentVersion = options.toVersion ?? getVersionFromFilename(migrationFileName, migrationFileNamePattern);
previousVersion = getVersionRangeFromLatestVersion(currentVersion, options.granularity);
}

const migrationMetadata = getLocalMetadataFile<MigrationData<unknown>[]>(join(options.migrationMetadataFolder, migrationFileName));

const packageLocator = `${context.target?.project}@${previousVersion}`;
const previousFile = await getFilesFromRegistry(packageLocator, [options.metadataPath], packageManager);
const newFile = getLocalMetadataFile(options.metadataPath);

const errors = checkMetadataFile(previousFile[options.metadataPath], newFile, migrationMetadata, options.allowBreakingChanges, comparator);

if (errors.length) {
return {
success: false,
error: errors.reduce(((message, error) => message.concat(error.message, '\n')), '')
};
} else {
return {
success: true
};
}
}

0 comments on commit 3a39949

Please sign in to comment.