UNPKG

@angular/core

Version:

Angular - the core framework

609 lines (602 loc) • 28.6 kB
'use strict'; /** * @license Angular v19.2.7 * (c) 2010-2025 Google LLC. https://angular.io/ * License: MIT */ 'use strict'; var ts = require('typescript'); require('os'); var checker = require('./checker-BNmiXJIJ.js'); var index$1 = require('./index-DMhRS_CK.js'); require('path'); var project_paths = require('./project_paths-C-UAB3jT.js'); var apply_import_manager = require('./apply_import_manager-2VufvTsB.js'); var index = require('./index-DJTfI03d.js'); require('@angular-devkit/core'); require('node:path/posix'); require('fs'); require('module'); require('url'); require('@angular-devkit/schematics'); require('./project_tsconfig_paths-CDVxT6Ov.js'); function isOutputDeclarationEligibleForMigration(node) { return (node.initializer !== undefined && ts.isNewExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === 'EventEmitter'); } function isPotentialOutputCallUsage(node, name) { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name)) { return node.expression?.name.text === name; } else { return false; } } function isPotentialPipeCallUsage(node) { return isPotentialOutputCallUsage(node, 'pipe'); } function isPotentialNextCallUsage(node) { return isPotentialOutputCallUsage(node, 'next'); } function isPotentialCompleteCallUsage(node) { return isPotentialOutputCallUsage(node, 'complete'); } function isTargetOutputDeclaration(node, checker, reflector, dtsReader) { const targetSymbol = checker.getSymbolAtLocation(node); if (targetSymbol !== undefined) { const propertyDeclaration = getTargetPropertyDeclaration(targetSymbol); if (propertyDeclaration !== null && isOutputDeclaration(propertyDeclaration, reflector, dtsReader)) { return propertyDeclaration; } } return null; } /** Gets whether the given property is an Angular `@Output`. */ function isOutputDeclaration(node, reflector, dtsReader) { // `.d.ts` file, so we check the `static ecmp` metadata on the `declare class`. if (node.getSourceFile().isDeclarationFile) { if (!ts.isIdentifier(node.name) || !ts.isClassDeclaration(node.parent) || node.parent.name === undefined) { return false; } const ref = new checker.Reference(node.parent); const directiveMeta = dtsReader.getDirectiveMetadata(ref); return !!directiveMeta?.outputs.getByClassPropertyName(node.name.text); } // `.ts` file, so we check for the `@Output()` decorator. return getOutputDecorator(node, reflector) !== null; } function getTargetPropertyDeclaration(targetSymbol) { const valDeclaration = targetSymbol.valueDeclaration; if (valDeclaration !== undefined && ts.isPropertyDeclaration(valDeclaration)) { return valDeclaration; } return null; } /** Returns Angular `@Output` decorator or null when a given property declaration is not an @Output */ function getOutputDecorator(node, reflector) { const decorators = reflector.getDecoratorsOfDeclaration(node); const ngDecorators = decorators !== null ? checker.getAngularDecorators(decorators, ['Output'], /* isCore */ false) : []; return ngDecorators.length > 0 ? ngDecorators[0] : null; } // THINK: this utility + type is not specific to @Output, really, maybe move it to tsurge? /** Computes an unique ID for a given Angular `@Output` property. */ function getUniqueIdForProperty(info, prop) { const { id } = project_paths.projectFile(prop.getSourceFile(), info); id.replace(/\.d\.ts$/, '.ts'); return `${id}@@${prop.parent.name ?? 'unknown-class'}@@${prop.name.getText()}`; } function isTestRunnerImport(node) { if (ts.isImportDeclaration(node)) { const moduleSpecifier = node.moduleSpecifier.getText(); return moduleSpecifier.includes('jasmine') || moduleSpecifier.includes('catalyst'); } return false; } // TODO: code duplication with signals migration - sort it out /** * Gets whether the given read is used to access * the specified field. * * E.g. whether `<my-read>.toArray` is detected. */ function checkNonTsReferenceAccessesField(ref, fieldName) { const readFromPath = ref.from.readAstPath.at(-1); const parentRead = ref.from.readAstPath.at(-2); if (ref.from.read !== readFromPath) { return null; } if (!(parentRead instanceof checker.PropertyRead) || parentRead.name !== fieldName) { return null; } return parentRead; } /** * Gets whether the given reference is accessed to call the * specified function on it. * * E.g. whether `<my-read>.toArray()` is detected. */ function checkNonTsReferenceCallsField(ref, fieldName) { const propertyAccess = checkNonTsReferenceAccessesField(ref, fieldName); if (propertyAccess === null) { return null; } const accessIdx = ref.from.readAstPath.indexOf(propertyAccess); if (accessIdx === -1) { return null; } const potentialRead = ref.from.readAstPath[accessIdx]; if (potentialRead === undefined) { return null; } return potentialRead; } const printer = ts.createPrinter(); function calculateDeclarationReplacement(info, node, aliasParam) { const sf = node.getSourceFile(); let payloadTypes; if (node.initializer && ts.isNewExpression(node.initializer) && node.initializer.typeArguments) { payloadTypes = node.initializer.typeArguments; } else if (node.type && ts.isTypeReferenceNode(node.type) && node.type.typeArguments) { payloadTypes = ts.factory.createNodeArray(node.type.typeArguments); } const outputCall = ts.factory.createCallExpression(ts.factory.createIdentifier('output'), payloadTypes, aliasParam !== undefined ? [ ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment('alias', ts.factory.createStringLiteral(aliasParam, true)), ], false), ] : []); const existingModifiers = (node.modifiers ?? []).filter((modifier) => !ts.isDecorator(modifier) && modifier.kind !== ts.SyntaxKind.ReadonlyKeyword); const updatedOutputDeclaration = ts.factory.createPropertyDeclaration( // Think: this logic of dealing with modifiers is applicable to all signal-based migrations ts.factory.createNodeArray([ ...existingModifiers, ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword), ]), node.name, undefined, undefined, outputCall); return prepareTextReplacementForNode(info, node, printer.printNode(ts.EmitHint.Unspecified, updatedOutputDeclaration, sf)); } function calculateImportReplacements(info, sourceFiles) { const importReplacements = {}; for (const sf of sourceFiles) { const importManager = new checker.ImportManager(); const addOnly = []; const addRemove = []; const file = project_paths.projectFile(sf, info); importManager.addImport({ requestedFile: sf, exportModuleSpecifier: '@angular/core', exportSymbolName: 'output', }); apply_import_manager.applyImportManagerChanges(importManager, addOnly, [sf], info); importManager.removeImport(sf, 'Output', '@angular/core'); importManager.removeImport(sf, 'EventEmitter', '@angular/core'); apply_import_manager.applyImportManagerChanges(importManager, addRemove, [sf], info); importReplacements[file.id] = { add: addOnly, addAndRemove: addRemove, }; } return importReplacements; } function calculateNextFnReplacement(info, node) { return prepareTextReplacementForNode(info, node, 'emit'); } function calculateNextFnReplacementInTemplate(file, span) { return prepareTextReplacement(file, 'emit', span.start, span.end); } function calculateNextFnReplacementInHostBinding(file, offset, span) { return prepareTextReplacement(file, 'emit', offset + span.start, offset + span.end); } function calculateCompleteCallReplacement(info, node) { return prepareTextReplacementForNode(info, node, '', node.getFullStart()); } function calculatePipeCallReplacement(info, node) { if (ts.isPropertyAccessExpression(node.expression)) { const sf = node.getSourceFile(); const importManager = new checker.ImportManager(); const outputToObservableIdent = importManager.addImport({ requestedFile: sf, exportModuleSpecifier: '@angular/core/rxjs-interop', exportSymbolName: 'outputToObservable', }); const toObsCallExp = ts.factory.createCallExpression(outputToObservableIdent, undefined, [ node.expression.expression, ]); const pipePropAccessExp = ts.factory.updatePropertyAccessExpression(node.expression, toObsCallExp, node.expression.name); const pipeCallExp = ts.factory.updateCallExpression(node, pipePropAccessExp, [], node.arguments); const replacements = [ prepareTextReplacementForNode(info, node, printer.printNode(ts.EmitHint.Unspecified, pipeCallExp, sf)), ]; apply_import_manager.applyImportManagerChanges(importManager, replacements, [sf], info); return replacements; } else { // TODO: assert instead? throw new Error(`Unexpected call expression for .pipe - expected a property access but got "${node.getText()}"`); } } function prepareTextReplacementForNode(info, node, replacement, start) { const sf = node.getSourceFile(); return new project_paths.Replacement(project_paths.projectFile(sf, info), new project_paths.TextUpdate({ position: start ?? node.getStart(), end: node.getEnd(), toInsert: replacement, })); } function prepareTextReplacement(file, replacement, start, end) { return new project_paths.Replacement(file, new project_paths.TextUpdate({ position: start, end: end, toInsert: replacement, })); } class OutputMigration extends project_paths.TsurgeFunnelMigration { config; constructor(config = {}) { super(); this.config = config; } async analyze(info) { const { sourceFiles, program } = info; const outputFieldReplacements = {}; const problematicUsages = {}; let problematicDeclarationCount = 0; const filesWithOutputDeclarations = new Set(); const checker$1 = program.getTypeChecker(); const reflector = new checker.TypeScriptReflectionHost(checker$1); const dtsReader = new index$1.DtsMetadataReader(checker$1, reflector); const evaluator = new index$1.PartialEvaluator(reflector, checker$1, null); const resourceLoader = info.ngCompiler?.['resourceManager'] ?? null; // Pre-analyze the program and get access to the template type checker. // If we are processing a non-Angular target, there is no template info. const { templateTypeChecker } = info.ngCompiler?.['ensureAnalyzed']() ?? { templateTypeChecker: null, }; const knownFields = { // Note: We don't support cross-target migration of `Partial<T>` usages. // This is an acceptable limitation for performance reasons. shouldTrackClassReference: () => false, attemptRetrieveDescriptorFromSymbol: (s) => { const propDeclaration = getTargetPropertyDeclaration(s); if (propDeclaration !== null) { const classFieldID = getUniqueIdForProperty(info, propDeclaration); if (classFieldID !== null) { return { node: propDeclaration, key: classFieldID, }; } } return null; }, }; let isTestFile = false; const outputMigrationVisitor = (node) => { // detect output declarations if (ts.isPropertyDeclaration(node)) { const outputDecorator = getOutputDecorator(node, reflector); if (outputDecorator !== null) { if (isOutputDeclarationEligibleForMigration(node)) { const outputDef = { id: getUniqueIdForProperty(info, node), aliasParam: outputDecorator.args?.at(0), }; const outputFile = project_paths.projectFile(node.getSourceFile(), info); if (this.config.shouldMigrate === undefined || this.config.shouldMigrate({ key: outputDef.id, node: node, }, outputFile)) { const aliasParam = outputDef.aliasParam; const aliasOptionValue = aliasParam ? evaluator.evaluate(aliasParam) : undefined; if (aliasOptionValue == undefined || typeof aliasOptionValue === 'string') { filesWithOutputDeclarations.add(node.getSourceFile()); addOutputReplacement(outputFieldReplacements, outputDef.id, outputFile, calculateDeclarationReplacement(info, node, aliasOptionValue?.toString())); } else { problematicUsages[outputDef.id] = true; problematicDeclarationCount++; } } } else { problematicDeclarationCount++; } } } // detect .next usages that should be migrated to .emit if (isPotentialNextCallUsage(node) && ts.isPropertyAccessExpression(node.expression)) { const propertyDeclaration = isTargetOutputDeclaration(node.expression.expression, checker$1, reflector, dtsReader); if (propertyDeclaration !== null) { const id = getUniqueIdForProperty(info, propertyDeclaration); const outputFile = project_paths.projectFile(node.getSourceFile(), info); addOutputReplacement(outputFieldReplacements, id, outputFile, calculateNextFnReplacement(info, node.expression.name)); } } // detect .complete usages that should be removed if (isPotentialCompleteCallUsage(node) && ts.isPropertyAccessExpression(node.expression)) { const propertyDeclaration = isTargetOutputDeclaration(node.expression.expression, checker$1, reflector, dtsReader); if (propertyDeclaration !== null) { const id = getUniqueIdForProperty(info, propertyDeclaration); const outputFile = project_paths.projectFile(node.getSourceFile(), info); if (ts.isExpressionStatement(node.parent)) { addOutputReplacement(outputFieldReplacements, id, outputFile, calculateCompleteCallReplacement(info, node.parent)); } else { problematicUsages[id] = true; } } } addCommentForEmptyEmit(node, info, checker$1, reflector, dtsReader, outputFieldReplacements); // detect imports of test runners if (isTestRunnerImport(node)) { isTestFile = true; } // detect unsafe access of the output property if (isPotentialPipeCallUsage(node) && ts.isPropertyAccessExpression(node.expression)) { const propertyDeclaration = isTargetOutputDeclaration(node.expression.expression, checker$1, reflector, dtsReader); if (propertyDeclaration !== null) { const id = getUniqueIdForProperty(info, propertyDeclaration); if (isTestFile) { const outputFile = project_paths.projectFile(node.getSourceFile(), info); addOutputReplacement(outputFieldReplacements, id, outputFile, ...calculatePipeCallReplacement(info, node)); } else { problematicUsages[id] = true; } } } ts.forEachChild(node, outputMigrationVisitor); }; // calculate output migration replacements for (const sf of sourceFiles) { isTestFile = false; ts.forEachChild(sf, outputMigrationVisitor); } // take care of the references in templates and host bindings const referenceResult = { references: [] }; const { visitor: templateHostRefVisitor } = index.createFindAllSourceFileReferencesVisitor(info, checker$1, reflector, resourceLoader, evaluator, templateTypeChecker, knownFields, null, // TODO: capture known output names as an optimization referenceResult); // calculate template / host binding replacements for (const sf of sourceFiles) { ts.forEachChild(sf, templateHostRefVisitor); } for (const ref of referenceResult.references) { // detect .next usages that should be migrated to .emit in template and host binding expressions if (ref.kind === index.ReferenceKind.InTemplate) { const callExpr = checkNonTsReferenceCallsField(ref, 'next'); // TODO: here and below for host bindings, we should ideally filter in the global meta stage // (instead of using the `outputFieldReplacements` map) // as technically, the call expression could refer to an output // from a whole different compilation unit (e.g. tsconfig.json). if (callExpr !== null && outputFieldReplacements[ref.target.key] !== undefined) { addOutputReplacement(outputFieldReplacements, ref.target.key, ref.from.templateFile, calculateNextFnReplacementInTemplate(ref.from.templateFile, callExpr.nameSpan)); } } else if (ref.kind === index.ReferenceKind.InHostBinding) { const callExpr = checkNonTsReferenceCallsField(ref, 'next'); if (callExpr !== null && outputFieldReplacements[ref.target.key] !== undefined) { addOutputReplacement(outputFieldReplacements, ref.target.key, ref.from.file, calculateNextFnReplacementInHostBinding(ref.from.file, ref.from.hostPropertyNode.getStart() + 1, callExpr.nameSpan)); } } } // calculate import replacements but do so only for files that have output declarations const importReplacements = calculateImportReplacements(info, filesWithOutputDeclarations); return project_paths.confirmAsSerializable({ problematicDeclarationCount, outputFields: outputFieldReplacements, importReplacements, problematicUsages, }); } async combine(unitA, unitB) { const outputFields = {}; const importReplacements = {}; const problematicUsages = {}; let problematicDeclarationCount = 0; for (const unit of [unitA, unitB]) { for (const declIdStr of Object.keys(unit.outputFields)) { const declId = declIdStr; // THINK: detect clash? Should we have an utility to merge data based on unique IDs? outputFields[declId] = unit.outputFields[declId]; } for (const fileIDStr of Object.keys(unit.importReplacements)) { const fileID = fileIDStr; importReplacements[fileID] = unit.importReplacements[fileID]; } problematicDeclarationCount += unit.problematicDeclarationCount; } for (const unit of [unitA, unitB]) { for (const declIdStr of Object.keys(unit.problematicUsages)) { const declId = declIdStr; problematicUsages[declId] = unit.problematicUsages[declId]; } } return project_paths.confirmAsSerializable({ problematicDeclarationCount, outputFields, importReplacements, problematicUsages, }); } async globalMeta(combinedData) { const globalMeta = { importReplacements: combinedData.importReplacements, outputFields: combinedData.outputFields, problematicDeclarationCount: combinedData.problematicDeclarationCount, problematicUsages: {}, }; for (const keyStr of Object.keys(combinedData.problematicUsages)) { const key = keyStr; // it might happen that a problematic usage is detected but we didn't see the declaration - skipping those if (globalMeta.outputFields[key] !== undefined) { globalMeta.problematicUsages[key] = true; } } // Noop here as we don't have any form of special global metadata. return project_paths.confirmAsSerializable(combinedData); } async stats(globalMetadata) { const detectedOutputs = new Set(Object.keys(globalMetadata.outputFields)).size + globalMetadata.problematicDeclarationCount; const problematicOutputs = new Set(Object.keys(globalMetadata.problematicUsages)).size + globalMetadata.problematicDeclarationCount; const successRate = detectedOutputs > 0 ? (detectedOutputs - problematicOutputs) / detectedOutputs : 1; return { counters: { detectedOutputs, problematicOutputs, successRate, }, }; } async migrate(globalData) { const migratedFiles = new Set(); const problematicFiles = new Set(); const replacements = []; for (const declIdStr of Object.keys(globalData.outputFields)) { const declId = declIdStr; const outputField = globalData.outputFields[declId]; if (!globalData.problematicUsages[declId]) { replacements.push(...outputField.replacements); migratedFiles.add(outputField.file.id); } else { problematicFiles.add(outputField.file.id); } } for (const fileIDStr of Object.keys(globalData.importReplacements)) { const fileID = fileIDStr; if (migratedFiles.has(fileID)) { const importReplacements = globalData.importReplacements[fileID]; if (problematicFiles.has(fileID)) { replacements.push(...importReplacements.add); } else { replacements.push(...importReplacements.addAndRemove); } } } return { replacements }; } } function addOutputReplacement(outputFieldReplacements, outputId, file, ...replacements) { let existingReplacements = outputFieldReplacements[outputId]; if (existingReplacements === undefined) { outputFieldReplacements[outputId] = existingReplacements = { file: file, replacements: [], }; } existingReplacements.replacements.push(...replacements); } function addCommentForEmptyEmit(node, info, checker, reflector, dtsReader, outputFieldReplacements) { if (!isEmptyEmitCall(node)) return; const propertyAccess = getPropertyAccess(node); if (!propertyAccess) return; const symbol = checker.getSymbolAtLocation(propertyAccess.name); if (!symbol || !symbol.declarations?.length) return; const propertyDeclaration = isTargetOutputDeclaration(propertyAccess, checker, reflector, dtsReader); if (!propertyDeclaration) return; const eventEmitterType = getEventEmitterArgumentType(propertyDeclaration); if (!eventEmitterType) return; const id = getUniqueIdForProperty(info, propertyDeclaration); const file = project_paths.projectFile(node.getSourceFile(), info); const formatter = getFormatterText(node); const todoReplacement = new project_paths.TextUpdate({ toInsert: `${formatter.indent}// TODO: The 'emit' function requires a mandatory ${eventEmitterType} argument\n`, end: formatter.lineStartPos, position: formatter.lineStartPos, }); addOutputReplacement(outputFieldReplacements, id, file, new project_paths.Replacement(file, todoReplacement)); } function isEmptyEmitCall(node) { return (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'emit' && node.arguments.length === 0); } function getPropertyAccess(node) { const propertyAccessExpression = node.expression.expression; return ts.isPropertyAccessExpression(propertyAccessExpression) ? propertyAccessExpression : null; } function getEventEmitterArgumentType(propertyDeclaration) { const initializer = propertyDeclaration.initializer; if (!initializer || !ts.isNewExpression(initializer)) return null; const isEventEmitter = ts.isIdentifier(initializer.expression) && initializer.expression.getText() === 'EventEmitter'; if (!isEventEmitter) return null; const [typeArg] = initializer.typeArguments ?? []; return typeArg ? typeArg.getText() : null; } function getFormatterText(node) { const sourceFile = node.getSourceFile(); const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); const lineStartPos = sourceFile.getPositionOfLineAndCharacter(line, 0); const indent = sourceFile.text.slice(lineStartPos, node.getStart()); return { indent, lineStartPos }; } function migrate(options) { return async (tree, context) => { await project_paths.runMigrationInDevkit({ tree, getMigration: (fs) => new OutputMigration({ shouldMigrate: (_, file) => { return (file.rootRelativePath.startsWith(fs.normalize(options.path)) && !/(^|\/)node_modules\//.test(file.rootRelativePath)); }, }), beforeProgramCreation: (tsconfigPath, stage) => { if (stage === project_paths.MigrationStage.Analysis) { context.logger.info(`Preparing analysis for: ${tsconfigPath}...`); } else { context.logger.info(`Running migration for: ${tsconfigPath}...`); } }, afterProgramCreation: (info, fs) => { const analysisPath = fs.resolve(options.analysisDir); // Support restricting the analysis to subfolders for larger projects. if (analysisPath !== '/') { info.sourceFiles = info.sourceFiles.filter((sf) => sf.fileName.startsWith(analysisPath)); info.fullProgramSourceFiles = info.fullProgramSourceFiles.filter((sf) => sf.fileName.startsWith(analysisPath)); } }, beforeUnitAnalysis: (tsconfigPath) => { context.logger.info(`Scanning for outputs: ${tsconfigPath}...`); }, afterAllAnalyzed: () => { context.logger.info(``); context.logger.info(`Processing analysis data between targets...`); context.logger.info(``); }, afterAnalysisFailure: () => { context.logger.error('Migration failed unexpectedly with no analysis data'); }, whenDone: ({ counters }) => { const { detectedOutputs, problematicOutputs, successRate } = counters; const migratedOutputs = detectedOutputs - problematicOutputs; const successRatePercent = (successRate * 100).toFixed(2); context.logger.info(''); context.logger.info(`Successfully migrated to outputs as functions 🎉`); context.logger.info(` -> Migrated ${migratedOutputs} out of ${detectedOutputs} detected outputs (${successRatePercent} %).`); }, }); }; } exports.migrate = migrate;