From b25cd6af7e78ea00674dc7a1399217c40269a4d9 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 6 Jul 2023 19:35:01 +0200 Subject: [PATCH] build: add a script to detect component ID collisions As of v16, the framework uses the metadata of a component to generate an ID. If two components have identical metadata, they may end up with the same ID which will log a warning. These changes add a script to detect such cases automatically. Relates to https://github.com/angular/components/issues/27163. --- package.json | 1 + scripts/detect-component-id-collisions.ts | 201 ++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 scripts/detect-component-id-collisions.ts diff --git a/package.json b/package.json index 0524752c87f3..06c264d39926 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "format": "yarn -s ng-dev format changed", "cherry-pick-patch": "ts-node --project tools/cherry-pick-patch/tsconfig.json tools/cherry-pick-patch/cherry-pick-patch.ts", "ownerslint": "ts-node --project scripts/tsconfig.json scripts/ownerslint.ts", + "detect-component-id-collisions": "ts-node --project scripts/tsconfig.json scripts/detect-component-id-collisions.ts", "tslint": "tslint -c tslint.json --project ./tsconfig.json", "stylelint": "stylelint \"src/**/*.+(css|scss)\" --config .stylelintrc.json", "resync-caretaker-app": "ts-node --project scripts/tsconfig.json scripts/caretaking/resync-caretaker-app-prs.ts", diff --git a/scripts/detect-component-id-collisions.ts b/scripts/detect-component-id-collisions.ts new file mode 100644 index 000000000000..8202bd7b9799 --- /dev/null +++ b/scripts/detect-component-id-collisions.ts @@ -0,0 +1,201 @@ +import ts from 'typescript'; +import chalk from 'chalk'; +import {readFileSync} from 'fs'; +import {join} from 'path'; +import {sync as glob} from 'glob'; + +// This script aims to detect if the unique IDs of two components may collide at runtime +// in order to avoid issues like https://github.com/angular/components/issues/27163. + +const errors: string[] = []; +const seenMetadata = new Map(); +const fileToCheck = join(__dirname, '../src/**/!(*.spec).ts'); +const ignoredPatterns = [ + '**/components-examples/**', + '**/dev-app/**', + '**/e2e-app/**', + '**/universal-app/**', +]; + +// Use glob + createSourceFile since we don't need type information +// and this generally faster than creating a program. +glob(fileToCheck, {absolute: true, ignore: ignoredPatterns}) + .map(path => ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true)) + .forEach(sourceFile => { + sourceFile.statements.forEach(statement => { + if (ts.isClassDeclaration(statement)) { + validateClass(statement); + } + }); + }); + +if (errors.length) { + console.error(chalk.red('Detected identical metadata between following components:')); + errors.forEach(err => console.error(chalk.red(err))); + console.error( + chalk.red( + `\nThe metadata of one of each of these components should be ` + + `changed to be slightly different in order to avoid conflicts at runtime.\n`, + ), + ); + + process.exit(1); +} + +/** Validates if a class will conflict with any of the classes that have been checked so far. */ +function validateClass(node: ts.ClassDeclaration): void { + const metadata = getComponentMetadata(node); + + if (metadata) { + // Create an ID for the component based on its metadata. This is based on what the framework + // does at runtime at https://github.com/angular/angular/blob/main/packages/core/src/render3/definition.ts#L679. + // Note that the behavior isn't exactly the same, because the framework uses some fields that + // are generated by the compiler based on the component's template. + const key = [ + serializeField('selector', metadata), + serializeField('host', metadata), + serializeField('encapsulation', metadata), + serializeField('standalone', metadata), + serializeField('signals', metadata), + serializeField('exportAs', metadata), + serializeBindings(node, metadata, 'inputs', 'Input'), + serializeBindings(node, metadata, 'outputs', 'Output'), + ].join('|'); + + if (seenMetadata.has(key)) { + errors.push(`- ${node.name?.text} and ${seenMetadata.get(key)!.name?.text}`); + } else { + seenMetadata.set(key, node); + } + } +} + +/** Serializes a field of an object literal node to a string. */ +function serializeField(name: string, metadata: ts.ObjectLiteralExpression): string { + const prop = findPropAssignment(name, metadata); + return prop ? serializeValue(prop.initializer).trim() : ''; +} + +/** Extracts the input/output bindings of a component and serializes them into a string. */ +function serializeBindings( + node: ts.ClassDeclaration, + metadata: ts.ObjectLiteralExpression, + metaName: string, + decoratorName: string, +): string { + const bindings: Record = {}; + const metaProp = findPropAssignment(metaName, metadata); + + if (metaProp && ts.isArrayLiteralExpression(metaProp.initializer)) { + metaProp.initializer.elements.forEach(el => { + if (ts.isStringLiteralLike(el)) { + const [name, alias] = el.text.split(':').map(p => p.trim()); + bindings[alias || name] = name; + } else if (ts.isObjectLiteralExpression(el)) { + const name = findPropAssignment('name', el); + const alias = findPropAssignment('alias', el); + + if (name && ts.isStringLiteralLike(name.initializer)) { + const publicName = + alias && ts.isStringLiteralLike(alias.initializer) + ? alias.initializer.text + : name.initializer.text; + bindings[publicName] = name.initializer.text; + } + } + }); + } + + node.members.forEach(member => { + if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) { + return; + } + + const decorator = findDecorator(decoratorName, member); + + if (decorator) { + const publicName = + decorator.expression.arguments.length > 0 && + ts.isStringLiteralLike(decorator.expression.arguments[0]) + ? decorator.expression.arguments[0].text + : member.name.text; + bindings[publicName] = member.name.text; + } + }); + + return JSON.stringify(bindings); +} + +/** Serializes a single value to a string. */ +function serializeValue(node: ts.Node): string { + if (ts.isStringLiteralLike(node) || ts.isIdentifier(node)) { + return node.text; + } + + if (ts.isArrayLiteralExpression(node)) { + return JSON.stringify(node.elements.map(serializeValue)); + } + + if (ts.isObjectLiteralExpression(node)) { + const serialized = node.properties + .slice() + // Sort the fields since JS engines preserve the order properties in object literals. + .sort((a, b) => (a.name?.getText() || '').localeCompare(b.name?.getText() || '')) + .reduce((accumulator, prop) => { + if (ts.isPropertyAssignment(prop)) { + accumulator[prop.name.getText()] = serializeValue(prop.initializer); + } + + return accumulator; + }, {} as Record); + + return JSON.stringify(serialized); + } + + return node.getText(); +} + +/** Gets the object literal containing the Angular component metadata of a class. */ +function getComponentMetadata(node: ts.ClassDeclaration): ts.ObjectLiteralExpression | null { + const decorator = findDecorator('Component', node); + + if (!decorator) { + return null; + } + + if ( + decorator.expression.arguments.length === 0 || + !ts.isObjectLiteralExpression(decorator.expression.arguments[0]) + ) { + throw new Error( + `Cannot analyze class ${node.name?.text || 'Anonymous'} in ${node.getSourceFile().fileName}.`, + ); + } + + return decorator.expression.arguments[0]; +} + +/** Finds a decorator with a specific name on a node. */ +function findDecorator(name: string, node: ts.HasDecorators) { + return ts + .getDecorators(node) + ?.find( + current => + ts.isCallExpression(current.expression) && + ts.isIdentifier(current.expression.expression) && + current.expression.expression.text === name, + ) as (ts.Decorator & {expression: ts.CallExpression}) | undefined; +} + +/** Finds a specific property of an object literal node. */ +function findPropAssignment( + name: string, + literal: ts.ObjectLiteralExpression, +): ts.PropertyAssignment | undefined { + return literal.properties.find( + current => + ts.isPropertyAssignment(current) && + ts.isIdentifier(current.name) && + current.name.text === name, + ) as ts.PropertyAssignment | undefined; +}