UNPKG

@ui5/linter

Version:

A static code analysis tool for UI5

967 lines (966 loc) 81.6 kB
import ts from "typescript"; import path from "node:path/posix"; import { getLogger } from "@ui5/logger"; import { CoverageCategory } from "../LinterContext.js"; import { MESSAGE } from "../messages.js"; import analyzeComponentJson from "./asyncComponentFlags.js"; import { deprecatedLibraries, deprecatedThemes } from "../../utils/deprecations.js"; import { getPropertyNameText, getSymbolForPropertyInConstructSignatures, getPropertyAssignmentInObjectLiteralExpression, getPropertyAssignmentsInObjectLiteralExpression, findClassMember, isClassMethod, isSourceFileOfPseudoModuleType, isSourceFileOfTypeScriptLib, getSymbolModuleDeclaration, isGlobalThis, extractNamespace, } from "./utils/utils.js"; import { taskStart } from "../../utils/perf.js"; import { getPositionsForNode } from "../../utils/nodePosition.js"; import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; import { findDirectives } from "./directives.js"; import BindingLinter from "../binding/BindingLinter.js"; import { createResource } from "@ui5/fs/resourceFactory"; import { getModuleTypeInfo, getNamespace, getUi5TypeInfoFromSymbol, Ui5TypeInfoKind, } from "./Ui5TypeInfo.js"; import GlobalFix from "./fix/GlobalFix.js"; const log = getLogger("linter:ui5Types:SourceFileLinter"); const QUNIT_FILE_EXTENSION = /\.qunit\.(js|ts)$/; // This is the same check as in the framework and prevents false-positives // https://github.com/SAP/openui5/blob/32c21c33d9dc29a32bf7ee7f41d7bae23dcf086b/src/sap.ui.core/src/sap/ui/test/starter/_utils.js#L287 const VALID_TESTSUITE = /\/testsuite(?:\.[a-z][a-z0-9-]*)*\.qunit\.(?:js|ts)$/; const DEPRECATED_VIEW_TYPES = ["JS", "JSON", "HTML", "Template"]; const ALLOWED_RENDERER_API_VERSIONS = ["2", "4"]; function isSourceFileOfUi5Type(sourceFile) { return /\/types\/(@openui5|@sapui5|@ui5\/linter\/types)\//.test(sourceFile.fileName); } function isSourceFileOfUi5OrThirdPartyType(sourceFile) { return isSourceFileOfUi5Type(sourceFile) || /\/types\/(@types\/jquery)\//.test(sourceFile.fileName); } function isSourceFileOfJquerySapType(sourceFile) { return [ "/types/@ui5/linter/types/jquery.sap.d.ts", "/types/@ui5/linter/types/jquery.sap.mobile.d.ts", ].includes(sourceFile.fileName); } export default class SourceFileLinter { typeLinter; sourceFile; checker; reportCoverage; messageDetails; apiExtract; filePathsWorkspace; workspace; ambientModuleCache; fixFactory; manifestContent; libraryDependencies; #reporter; #boundVisitNode; #fileName; #isComponent; #hasTestStarterFindings; #metadata; #xmlContents; fixHelpers; resourcePath; constructor(typeLinter, sourceFile, checker, reportCoverage = false, messageDetails = false, apiExtract, filePathsWorkspace, workspace, ambientModuleCache, fixFactory, manifestContent, libraryDependencies) { this.typeLinter = typeLinter; this.sourceFile = sourceFile; this.checker = checker; this.reportCoverage = reportCoverage; this.messageDetails = messageDetails; this.apiExtract = apiExtract; this.filePathsWorkspace = filePathsWorkspace; this.workspace = workspace; this.ambientModuleCache = ambientModuleCache; this.fixFactory = fixFactory; this.manifestContent = manifestContent; this.libraryDependencies = libraryDependencies; this.#reporter = typeLinter.getSourceFileReporter(sourceFile); this.#boundVisitNode = this.visitNode.bind(this); this.resourcePath = sourceFile.fileName; this.#fileName = path.basename(this.resourcePath); this.#isComponent = this.#fileName === "Component.js" || this.#fileName === "Component.ts"; this.#hasTestStarterFindings = false; this.#metadata = this.typeLinter.getContext().getMetadata(this.resourcePath); this.#xmlContents = []; this.fixHelpers = { checker: this.checker, manifestContent: this.manifestContent, libraryDependencies: this.libraryDependencies, }; } async lint() { try { if (!this.#metadata.directives) { // Directives might have already been extracted by the amd transpiler // This is done since the transpile process might loose comments findDirectives(this.sourceFile, this.#metadata); } this.visitNode(this.sourceFile); if (this.sourceFile.fileName.endsWith(".qunit.js") && // TS files do not have sap.ui.define !this.#metadata?.transformedImports?.get("sap.ui.define")) { this.#reportTestStarter(this.sourceFile); } let i = 0; for (const xmlContent of this.#xmlContents) { const fileName = `${this.sourceFile.fileName.replace(/(\.js|\.ts)$/, "")}.inline-${++i}.${xmlContent.documentKind}.xml`; const metadata = this.typeLinter.getContext().getMetadata(fileName); const newResource = createResource({ path: fileName, string: xmlContent.xml }); metadata.jsToXmlPosMapping = { pos: xmlContent.pos, originalPath: this.sourceFile.fileName, }; await Promise.all([ await this.filePathsWorkspace.write(newResource), await this.workspace.write(newResource), ]); } } catch (err) { const message = err instanceof Error ? err.message : String(err); log.verbose(`Error while linting ${this.resourcePath}: ${message}`); if (err instanceof Error) { log.verbose(`Call stack: ${err.stack}`); } this.typeLinter.getContext().addLintingMessage(this.resourcePath, MESSAGE.PARSING_ERROR, { message }); } } visitNode(node) { if (node.kind === ts.SyntaxKind.NewExpression) { // e.g. "new Button({\n\t\t\t\tblocked: true\n\t\t\t})" this.analyzeNewExpression(node); } else if (node.kind === ts.SyntaxKind.CallExpression) { // ts.isCallLikeExpression too? // const nodeType = this.checker.getTypeAtLocation(node); this.analyzePropertyAccessExpression(node); // Check for global this.analyzeCallExpression(node); // Check for deprecation } else if (node.kind === ts.SyntaxKind.PropertyAccessExpression || node.kind === ts.SyntaxKind.ElementAccessExpression) { // First, check for deprecation const deprecationMessageReported = this.analyzePropertyAccessExpressionForDeprecation(node); // If not deprecated, check for global. // We prefer the deprecation message over the global one as it contains more information. if (!deprecationMessageReported) { this.analyzePropertyAccessExpression(node); // Check for global } this.analyzeExportedValuesByLib(node); } else if (node.kind === ts.SyntaxKind.ObjectBindingPattern && node.parent?.kind === ts.SyntaxKind.VariableDeclaration) { // e.g. `const { Button } = sap.m;` // This is a destructuring assignment and we need to check each property for deprecation this.analyzeObjectBindingPattern(node); } else if (node.kind === ts.SyntaxKind.ImportDeclaration) { this.analyzeImportDeclaration(node); // Check for deprecation } else if (this.#isComponent && ts.isClassDeclaration(node) && this.isUi5ClassDeclaration(node, "sap/ui/core/Component")) { analyzeComponentJson({ classDeclaration: node, manifestContent: this.manifestContent, resourcePath: this.resourcePath, reporter: this.#reporter, context: this.typeLinter.getContext(), checker: this.checker, isUiComponent: this.isUi5ClassDeclaration(node, "sap/ui/core/UIComponent"), }); } else if (ts.isPropertyDeclaration(node) && getPropertyNameText(node.name) === "metadata" && node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword) && ts.isClassDeclaration(node.parent) && this.isUi5ClassDeclaration(node.parent, "sap/ui/base/ManagedObject")) { const visitMetadataNodes = (childNode) => { if (ts.isPropertyAssignment(childNode)) { // Skip nodes out of interest this.analyzeMetadataProperty(childNode); } ts.forEachChild(childNode, visitMetadataNodes); }; ts.forEachChild(node, visitMetadataNodes); } else if (ts.isClassDeclaration(node) && this.isUi5ClassDeclaration(node, "sap/ui/core/Control")) { this.analyzeControlRendererDeclaration(node); this.analyzeControlRerenderMethod(node); } else if (ts.isPropertyAssignment(node) && getPropertyNameText(node.name) === "theme") { this.analyzeTestsuiteThemeProperty(node); } // Traverse the whole AST from top to bottom ts.forEachChild(node, this.#boundVisitNode); } isUi5ClassDeclaration(node, baseClassModule) { const baseClassModules = Array.isArray(baseClassModule) ? baseClassModule : [baseClassModule]; const baseClasses = baseClassModules.map((baseClassModule) => { return { module: baseClassModule, name: baseClassModule.split("/").pop() }; }); // Go up the hierarchy chain to find whether the class extends from the provided base class const isClassUi5Subclass = (node) => { return node?.heritageClauses?.flatMap((parentClasses) => { return parentClasses.types.map((parentClass) => { const parentClassType = this.checker.getTypeAtLocation(parentClass); return parentClassType.symbol?.declarations?.some((declaration) => { if (!ts.isClassDeclaration(declaration)) { return false; } for (const baseClass of baseClasses) { if (declaration.name?.text === baseClass.name && ( // Declaration via type definitions (declaration.parent.parent && ts.isModuleDeclaration(declaration.parent.parent) && declaration.parent.parent.name?.text === baseClass.module) || // Declaration via real class (e.g. within sap.ui.core project) (ts.isSourceFile(declaration.parent) && (declaration.parent.fileName === `/resources/${baseClass.module}.js` || declaration.parent.fileName === `/resources/${baseClass.module}.ts`)))) { return true; } } return isClassUi5Subclass(declaration); }); }); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing }).reduce((acc, cur) => cur || acc, false) ?? false; }; return isClassUi5Subclass(node); } analyzeControlRendererDeclaration(node) { const className = node.name?.text ?? "<unknown>"; const rendererMember = findClassMember(node, "renderer", [{ modifier: ts.SyntaxKind.StaticKeyword }]); if (!rendererMember) { const nonStaticRender = findClassMember(node, "renderer"); if (nonStaticRender) { // Renderer must be a static member this.#reporter.addMessage(MESSAGE.NOT_STATIC_CONTROL_RENDERER, { className }, { node: nonStaticRender }); return; } // Special cases: Some base classes do not require sub-classes to have a renderer defined: if (this.isUi5ClassDeclaration(node, [ "sap/ui/core/mvc/View", // XMLComposite is deprecated, but there still shouldn't be a false-positive about a missing renderer "sap/ui/core/XMLComposite", "sap/ui/core/webc/WebComponent", "sap/uxap/BlockBase", ])) { return; } // No definition of renderer causes the runtime to load the corresponding Renderer module synchronously this.#reporter.addMessage(MESSAGE.MISSING_CONTROL_RENDERER_DECLARATION, { className }, { node }); return; } if (ts.isPropertyDeclaration(rendererMember) && rendererMember.initializer) { const initializerType = this.checker.getTypeAtLocation(rendererMember.initializer); if (initializerType.flags & ts.TypeFlags.Undefined || initializerType.flags & ts.TypeFlags.Null) { // null / undefined can be used to declare that a control does not have a renderer return; } if (initializerType.flags & ts.TypeFlags.StringLiteral) { let rendererName; if (ts.isStringLiteralLike(rendererMember.initializer)) { rendererName = rendererMember.initializer.text; } // Declaration as string requires sync loading of renderer module this.#reporter.addMessage(MESSAGE.CONTROL_RENDERER_DECLARATION_STRING, { className, rendererName, }, { node: rendererMember.initializer }); } // Analyze renderer property when it's referenced by a variable or even another module // i.e. { renderer: Renderer } if (ts.isIdentifier(rendererMember.initializer)) { const { symbol } = this.checker.getTypeAtLocation(rendererMember); const originalDeclarations = symbol?.getDeclarations()?.filter((decl) => !decl.getSourceFile().isDeclarationFile); // If the original raw render file is available, we can analyze it directly originalDeclarations?.forEach((declaration) => this.analyzeControlRendererInternals(declaration)); } else { // Analyze renderer property when it's directly embedded in the renderer object // i.e. { renderer: {apiVersion: 2, render: () => {}} } this.analyzeControlRendererInternals(rendererMember.initializer); } } } analyzeControlRendererInternals(node) { const findApiVersionNode = (potentialApiVersionNode) => { // const myControlRenderer = {apiVersion: 2, render: () => {}} if (ts.isObjectLiteralExpression(potentialApiVersionNode)) { const foundNode = getPropertyAssignmentInObjectLiteralExpression("apiVersion", potentialApiVersionNode); if (foundNode) { return foundNode; } } // const myControlRenderer = {} // const myControlRenderer.apiVersion = 2; let rendererObjectName = null; if ((ts.isPropertyAssignment(potentialApiVersionNode.parent) || ts.isPropertyDeclaration(potentialApiVersionNode.parent) || ts.isVariableDeclaration(potentialApiVersionNode.parent)) && ts.isIdentifier(potentialApiVersionNode.parent.name)) { rendererObjectName = potentialApiVersionNode.parent.name.text; } let apiVersionNode; const visitChildNodes = (childNode) => { if (ts.isBinaryExpression(childNode)) { if (ts.isPropertyAccessExpression(childNode.left)) { let objectName; if (ts.isIdentifier(childNode.left.expression)) { objectName = childNode.left.expression.text; // myControlRenderer } let propertyName; if (ts.isIdentifier(childNode.left.name)) { propertyName = childNode.left.name.text; // apiVersion } if (objectName === rendererObjectName && propertyName === "apiVersion") { apiVersionNode = childNode.right; } } } if (!apiVersionNode) { // If found, stop traversing ts.forEachChild(childNode, visitChildNodes); } }; ts.forEachChild(potentialApiVersionNode.getSourceFile(), visitChildNodes); return apiVersionNode; }; const getNodeToHighlight = (apiVersionNode) => { if (!apiVersionNode) { // No 'apiVersion' property return node; } if (ts.isPropertyAssignment(apiVersionNode)) { apiVersionNode = apiVersionNode.initializer; } if (!ts.isNumericLiteral(apiVersionNode)) { return apiVersionNode; } if (!ALLOWED_RENDERER_API_VERSIONS.includes(apiVersionNode.text)) { return apiVersionNode; } return undefined; }; const nodeType = this.checker.getTypeAtLocation(node); const nodeValueDeclaration = nodeType.getSymbol()?.valueDeclaration; // Analyze renderer property when it's an ObjectLiteralExpression // i.e. { renderer: {apiVersion: 2, render: () => {}} } if (node && (ts.isObjectLiteralExpression(node) || ts.isVariableDeclaration(node))) { const apiVersionNode = findApiVersionNode(node); const nodeToHighlight = getNodeToHighlight(apiVersionNode); if (nodeToHighlight) { // The findings can be in different file i.e. Control being analyzed, // but reporting might be in ControlRenderer const nodeSourceFile = nodeToHighlight.getSourceFile(); this.typeLinter.getSourceFileReporter(nodeSourceFile) .addMessage(MESSAGE.NO_DEPRECATED_RENDERER, null, { node: nodeToHighlight }); } this.analyzeIconCallInRenderMethod(node); // Analyze renderer property when it's a function i.e. { renderer: () => {} } } else if (ts.isMethodDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || (nodeValueDeclaration && (ts.isFunctionExpression(nodeValueDeclaration) || ts.isFunctionDeclaration(nodeValueDeclaration) || ts.isArrowFunction(nodeValueDeclaration)))) { // The findings can be in different file i.e. Control being analyzed, // but reporting might be in ControlRenderer const nodeSourceFile = node.getSourceFile(); this.typeLinter.getSourceFileReporter(nodeSourceFile) .addMessage(MESSAGE.NO_DEPRECATED_RENDERER, null, { node }); this.analyzeIconCallInRenderMethod(node); } } // If there's an oRm.icon() call in the render method, we need to check if IconPool is imported. // Currently, we're only able to analyze whether oRm.icon is called in the render method as // there's no reliable way to find if the method icon() is actually a member of RenderManager in other places. analyzeIconCallInRenderMethod(node) { let renderMethodNode = node; // When the render is a plain function if (ts.isMethodDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) { renderMethodNode = node; } else if (ts.isObjectLiteralExpression(node)) { // When the render is embed into the renderer object const renderProperty = getPropertyAssignmentInObjectLiteralExpression("render", node); renderMethodNode = (renderProperty && ts.isPropertyAssignment(renderProperty)) ? renderProperty.initializer : undefined; // When the renderer is a separate module and the render method is assigned // to the renderer object later i.e. myControlRenderer.render = function (oRm, oMyControl) {} if (!renderMethodNode) { let rendererObjectName = null; if ((ts.isPropertyAssignment(node.parent) || ts.isPropertyDeclaration(node.parent) || ts.isVariableDeclaration(node.parent)) && ts.isIdentifier(node.parent.name)) { rendererObjectName = node.parent.name.text; } const findRenderMethod = (childNode) => { if (ts.isBinaryExpression(childNode)) { if (ts.isPropertyAccessExpression(childNode.left)) { let objectName; if (ts.isIdentifier(childNode.left.expression)) { objectName = childNode.left.expression.text; // myControlRenderer } let propertyName; if (ts.isIdentifier(childNode.left.name)) { propertyName = childNode.left.name.text; // render } if (objectName === rendererObjectName && propertyName === "render") { renderMethodNode = childNode.right; } } } if (!renderMethodNode) { // If found, stop traversing ts.forEachChild(childNode, findRenderMethod); } }; ts.forEachChild(node.getSourceFile(), findRenderMethod); } } if (!renderMethodNode) { return; } // If there's a dependency to IconPool from the Renderer, // it's fine and we can skip the rest of the checks const rendererSource = renderMethodNode.getSourceFile(); const hasIconPoolImport = rendererSource.statements.some((importNode) => { return ts.isImportDeclaration(importNode) && ts.isStringLiteralLike(importNode.moduleSpecifier) && importNode.moduleSpecifier.text === "sap/ui/core/IconPool"; }); if (hasIconPoolImport) { return; } const findIconCallExpression = (childNode) => { if (ts.isCallExpression(childNode) && ts.isPropertyAccessExpression(childNode.expression) && childNode.expression.name.text === "icon") { // The findings can be in different file i.e. Control being analyzed, // but reporting might be in ControlRenderer, so we have to use the corresponding reporter const nodeSourceFile = childNode.getSourceFile(); this.typeLinter.getSourceFileReporter(nodeSourceFile) .addMessage(MESSAGE.NO_ICON_POOL_RENDERER, null, { node: childNode }); } ts.forEachChild(childNode, findIconCallExpression); }; // When the renderer is a separate module we can say with some certainty that the .icon() call // is a RenderManager's if (rendererSource.fileName !== this.sourceFile.fileName) { ts.forEachChild(rendererSource, findIconCallExpression); } else { // When the renderer is embedded in the control file, then we can analyze only the icon call // within the render method. ts.forEachChild(renderMethodNode, findIconCallExpression); } } analyzeControlRerenderMethod(node) { const className = node.name?.text ?? "<unknown>"; // Search for the rerender instance method const rerenderMember = findClassMember(node, "rerender", [{ not: true, modifier: ts.SyntaxKind.StaticKeyword }]); if (!rerenderMember || !isClassMethod(rerenderMember, this.checker)) { return; } this.#reporter.addMessage(MESSAGE.NO_CONTROL_RERENDER_OVERRIDE, { className }, { node: rerenderMember }); } analyzeMetadataProperty(node) { const type = getPropertyNameText(node.name); if (!type) { return; } const analyzeMetadataDone = taskStart(`analyzeMetadataProperty: ${type}`, this.resourcePath, true); if (type === "interfaces") { if (ts.isArrayLiteralExpression(node.initializer)) { node.initializer.elements.forEach((elem) => { const interfaceName = elem.text; const deprecationInfo = this.apiExtract.getDeprecationInfo(interfaceName); if (deprecationInfo) { this.#reporter.addMessage(MESSAGE.DEPRECATED_INTERFACE, { interfaceName: interfaceName, details: deprecationInfo.text, }, { node: elem }); } }); } } else if (type === "altTypes" && ts.isArrayLiteralExpression(node.initializer)) { node.initializer.elements.forEach((element) => { const nodeType = ts.isStringLiteralLike(element) ? element.text : ""; const deprecationInfo = this.apiExtract.getDeprecationInfo(nodeType); if (deprecationInfo) { this.#reporter.addMessage(MESSAGE.DEPRECATED_TYPE, { typeName: nodeType, details: deprecationInfo.text, }, { node: element }); } }); } else if (type === "defaultValue") { const defaultValueType = ts.isStringLiteralLike(node.initializer) ? node.initializer.text : ""; const typeNode = getPropertyAssignmentInObjectLiteralExpression("type", node.parent); const fullyQuantifiedName = (typeNode && ts.isPropertyAssignment(typeNode) && ts.isStringLiteralLike(typeNode.initializer)) ? [typeNode.initializer.text, defaultValueType].join(".") : ""; const deprecationInfo = this.apiExtract.getDeprecationInfo(fullyQuantifiedName); if (deprecationInfo) { this.#reporter.addMessage(MESSAGE.DEPRECATED_TYPE, { typeName: defaultValueType, details: deprecationInfo.text, }, { node }); } // This one is too generic and should always be at the last place // It's for "types" and event arguments' types } else if (ts.isStringLiteralLike(node.initializer)) { // Strip all the complex type definitions and create a list of "simple" types // i.e. Record<string, Map<my.custom.type, Record<another.type, number[]>>> // -> string, my.custom.type, another.type, number const nodeTypes = node.initializer.text.replace(/\w+<|>|\[\]/gi, "") .split(",").map((type) => type.trim()); nodeTypes.forEach((nodeType) => { const deprecationInfo = this.apiExtract.getDeprecationInfo(nodeType); if (deprecationInfo?.symbolKind === "UI5Class") { this.#reporter.addMessage(MESSAGE.DEPRECATED_CLASS, { className: nodeType, details: deprecationInfo.text, }, { node: node.initializer }); } else if (deprecationInfo?.symbolKind === "UI5Typedef" || deprecationInfo?.symbolKind === "UI5Enum") { this.#reporter.addMessage(MESSAGE.DEPRECATED_TYPE, { typeName: nodeType, details: deprecationInfo.text, }, { node: node.initializer }); } }); } analyzeMetadataDone(); } analyzeIdentifier(node) { const type = this.checker.getTypeAtLocation(node); if (!type?.symbol || !this.isSymbolOfUi5OrThirdPartyType(type.symbol)) { return false; } const deprecationInfo = this.getDeprecationInfo(type.symbol); if (deprecationInfo) { this.#reporter.addMessage(MESSAGE.DEPRECATED_API_ACCESS, { apiName: node.text, details: deprecationInfo.messageDetails, }, { node, ui5TypeInfo: deprecationInfo.ui5TypeInfo, fix: this.getFix(node, deprecationInfo.ui5TypeInfo), }); return true; } return false; } analyzeObjectBindingPattern(node) { const objectBindingPatternType = this.checker.getTypeAtLocation(node); node.elements.forEach((element) => { if (element.kind === ts.SyntaxKind.BindingElement && element.name.kind === ts.SyntaxKind.Identifier) { let identifier; if (element.propertyName) { if (!ts.isIdentifier(element.propertyName)) { return; } identifier = element.propertyName; } else { identifier = element.name; } if (this.analyzeIdentifier(identifier)) { return; } const property = objectBindingPatternType.getProperty(identifier.text); const deprecationInfo = this.getDeprecationInfo(property); if (deprecationInfo) { this.#reporter.addMessage(MESSAGE.DEPRECATED_API_ACCESS, { apiName: identifier.text, details: deprecationInfo.messageDetails, }, { node: element.name, ui5TypeInfo: deprecationInfo.ui5TypeInfo, fix: this.getFix(element, deprecationInfo.ui5TypeInfo), }); return; } } // Currently this lacks support for handling nested destructuring, e.g. // `const { SomeObject: { SomeOtherObject } } = coreLib;` // Also not covered is destructuring with computed property names, e.g. // const propName = "SomeObject" // const {[propName]: SomeVar} = coreLib; // Neither is expected to be relevant in the context of UI5 API usage. }); } analyzeNewExpression(node) { if (this.hasQUnitFileExtension() && ((ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "jsUnitTestSuite") || (ts.isIdentifier(node.expression) && node.expression.text === "jsUnitTestSuite"))) { this.#reportTestStarter(node); } const nodeType = this.checker.getTypeAtLocation(node); // checker.getContextualType(node); if (!nodeType.symbol || !this.isSymbolOfUi5OrThirdPartyType(nodeType.symbol)) { return; } const classType = this.checker.getTypeAtLocation(node.expression); const ui5TypeInfo = getUi5TypeInfoFromSymbol(nodeType.symbol, this.apiExtract); if (!ui5TypeInfo) { return; } const ui5ModuleTypeInfo = getModuleTypeInfo(ui5TypeInfo); if (!ui5ModuleTypeInfo) { return; } if (ui5ModuleTypeInfo?.kind === Ui5TypeInfoKind.Module && ui5ModuleTypeInfo.name === "sap/ui/core/routing/Router") { this.#analyzeNewCoreRouter(node); } else if (nodeType.symbol.declarations?.some((declaration) => ts.isClassDeclaration(declaration) && this.isUi5ClassDeclaration(declaration, "sap/ui/base/ManagedObject"))) { const originalFilename = this.#metadata?.xmlCompiledResource; // Do not process xml-s. This case would be handled separately within the BindingParser if (!originalFilename || ![".view.xml", ".fragment.xml"].some((ending) => originalFilename.endsWith(ending))) { this.#analyzeNewAndApplySettings(node); } } if (!node.arguments?.length) { // Nothing to check return; } // There can be multiple and we need to find the right one const allConstructSignatures = classType.getConstructSignatures(); // We can ignore all signatures that have a different number of parameters const possibleConstructSignatures = allConstructSignatures.filter((constructSignature) => { return constructSignature.getParameters().length === node.arguments?.length; }); node.arguments.forEach((arg, argIdx) => { // Only handle object literals, ignoring the optional first id argument or other unrelated arguments if (!ts.isObjectLiteralExpression(arg)) { return; } arg.properties.forEach((prop) => { if (!ts.isPropertyAssignment(prop)) { return; } const propertyName = getPropertyNameText(prop.name); if (!propertyName) { return; } const propertySymbol = getSymbolForPropertyInConstructSignatures(possibleConstructSignatures, argIdx, propertyName); if (!propertySymbol) { return; } const deprecationInfo = this.getDeprecationInfo(propertySymbol); if (deprecationInfo) { this.#reporter.addMessage(MESSAGE.DEPRECATED_PROPERTY_OF_CLASS, { propertyName: propertySymbol.escapedName, className: this.checker.typeToString(nodeType), details: deprecationInfo.messageDetails, }, { node: prop, ui5TypeInfo: deprecationInfo.ui5TypeInfo, fix: this.getFix(prop, deprecationInfo.ui5TypeInfo), }); return; } let reportMessage; if (ui5ModuleTypeInfo?.kind === Ui5TypeInfoKind.Module && ui5ModuleTypeInfo.name === "sap/ui/model/odata/v4/ODataModel" && propertyName === "synchronizationMode") { reportMessage = MESSAGE.DEPRECATED_ODATA_MODEL_V4_SYNCHRONIZATION_MODE; } if (!reportMessage) { return; } const ui5TypeInfo = getUi5TypeInfoFromSymbol(propertySymbol, this.apiExtract); this.#reporter.addMessage(reportMessage, null, { node: prop, fix: this.getFix(prop, ui5TypeInfo), }); }); }); } getDeprecationText(deprecatedTag) { // (Workaround) There's an issue in some UI5 TS definition versions and where the // deprecation text gets merged with the description. Splitting on double // new line could be considered as a clear separation between them. // https://github.com/SAP/ui5-typescript/issues/429 return deprecatedTag.text?.reduce((acc, text) => acc + text.text, "").split("\n\n")[0] ?? ""; } getDeprecationInfo(symbol) { if (symbol && this.isSymbolOfUi5Type(symbol)) { const jsdocTags = symbol.getJsDocTags(this.checker); const deprecatedTag = jsdocTags.find((tag) => tag.name === "deprecated"); if (deprecatedTag) { const deprecationInfo = { symbol, messageDetails: "", ui5TypeInfo: getUi5TypeInfoFromSymbol(symbol, this.apiExtract), }; if (this.messageDetails) { deprecationInfo.messageDetails = this.getDeprecationText(deprecatedTag); } return deprecationInfo; } } return null; } analyzeCallExpression(node) { const exprNode = node.expression; const exprType = this.checker.getTypeAtLocation(exprNode); if (!exprType?.symbol || !this.isSymbolOfUi5OrThirdPartyType(exprType.symbol)) { if (this.reportCoverage) { this.handleCallExpressionUnknownType(exprType, node); } return; } // Note: Always retrieving the type of the call expression causes the type checker to analyze the // return type, which seems to improve the accuracy of the type information. // See NoDeprecatedApi test case "ControllerByIdThisContext.js" this.checker.getTypeAtLocation(node); if (ts.isNewExpression(exprNode)) { // e.g. new Class()(); // This is usually unexpected and there are currently no known deprecations of functions // returned by a class constructor. // However, the OPA Matchers are a known exception where constructors do return a function. return; } else if (exprNode.kind === ts.SyntaxKind.SuperKeyword) { // Ignore super calls return; } if (!ts.isPropertyAccessExpression(exprNode) && // Lib.init() !ts.isElementAccessExpression(exprNode) && // Lib["init"]() !ts.isIdentifier(exprNode) && // Assignment `const LibInit = Library.init` and destructuring !ts.isCallExpression(exprNode)) { // TODO: Transform into coverage message if it's really ok not to handle this throw new Error(`Unhandled CallExpression expression syntax: ${ts.SyntaxKind[exprNode.kind]}`); } const ui5TypeInfo = getUi5TypeInfoFromSymbol(exprType.symbol, this.apiExtract); let globalApiName; if (ui5TypeInfo) { if (ui5TypeInfo.kind === Ui5TypeInfoKind.Function) { const namespace = getNamespace(ui5TypeInfo); if (namespace) { globalApiName = namespace + "." + ui5TypeInfo.name; if (globalApiName === "sap.ui.view" || globalApiName === "sap.ui.xmlview" || globalApiName === "sap.ui.fragment" || globalApiName === "sap.ui.xmlfragment") { this.#extractXmlFromJs(node, globalApiName); } } } else if ("name" in ui5TypeInfo) { const moduleTypeInfo = getModuleTypeInfo(ui5TypeInfo); if (moduleTypeInfo) { const moduleName = moduleTypeInfo.name; const symbolName = ui5TypeInfo.name; let instanceType = undefined; if (ts.isElementAccessExpression(exprNode) || ts.isPropertyAccessExpression(exprNode)) { instanceType = this.checker.getTypeAtLocation(exprNode.expression); } if (symbolName === "init" && moduleName === "sap/ui/core/Lib") { // Check for sap/ui/core/Lib.init usages this.#analyzeLibInitCall(node, exprNode); } else if (symbolName === "get" && moduleName === "sap/ui/core/theming/Parameters") { this.#analyzeParametersGetCall(node); } else if (symbolName === "createComponent" && moduleName === "sap/ui/core/Component") { this.#analyzeCreateComponentCall(node); } else if (symbolName === "loadData" && moduleName === "sap/ui/model/json/JSONModel") { this.#analyzeJsonModelLoadDataCall(node); } else if (symbolName === "createEntry" && moduleName === "sap/ui/model/odata/v2/ODataModel") { this.#analyzeOdataModelV2CreateEntry(node); } else if (symbolName === "init" && moduleName === "sap/ui/util/Mobile") { this.#analyzeMobileInit(node); } else if (symbolName === "setTheme" && moduleName === "sap/ui/core/Theming") { this.#analyzeThemingSetTheme(node); } else if (symbolName === "create" && moduleName === "sap/ui/core/mvc/View") { this.#analyzeViewCreate(node); } else if ((symbolName === "load" && moduleName === "sap/ui/core/Fragment") || // Controller#loadFragment calls Fragment.load internally (symbolName === "loadFragment" && moduleName === "sap/ui/core/mvc/Controller")) { this.#analyzeFragmentLoad(node, symbolName); } else if (this.hasQUnitFileExtension() && !VALID_TESTSUITE.test(this.sourceFile.fileName) && symbolName === "ready" && moduleName === "sap/ui/core/Core") { this.#reportTestStarter(node); } else if (symbolName === "applySettings" && instanceType?.symbol?.declarations?.some((declaration) => ts.isClassDeclaration(declaration) && this.isUi5ClassDeclaration(declaration, "sap/ui/base/ManagedObject"))) { this.#analyzeNewAndApplySettings(node); } else if (["bindProperty", "bindAggregation"].includes(symbolName) && moduleName === "sap/ui/base/ManagedObject" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) { this.#analyzePropertyBindings(node.arguments[1], ["type", "formatter"]); } else if (symbolName.startsWith("bind") && instanceType?.symbol?.declarations?.some((declaration) => ts.isClassDeclaration(declaration) && this.isUi5ClassDeclaration(declaration, "sap/ui/base/ManagedObject")) && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])) { // Setting names in UI5 are case sensitive. So, we're not sure of the exact name of the // property. // Check decapitalized version of the property name as well. const propName = symbolName.replace("bind", ""); const alternativePropName = propName.charAt(0).toLowerCase() + propName.slice(1); if (this.#isPropertyBinding(node, [propName, alternativePropName])) { this.#analyzePropertyBindings(node.arguments[0], ["type", "formatter"]); } } else if (symbolName === "create" && moduleName === "sap/ui/core/mvc/XMLView") { this.#extractXmlFromJs(node, "XMLView.create"); } } } } const deprecationInfo = this.getDeprecationInfo(exprType.symbol); if (!deprecationInfo) { return; } let reportNode; if (ts.isPropertyAccessExpression(exprNode)) { reportNode = exprNode.name; } else if (ts.isElementAccessExpression(exprNode)) { reportNode = exprNode.argumentExpression; } else { // Identifier / CallExpression reportNode = exprNode; } let additionalMessage = ""; if (exprNode.kind === ts.SyntaxKind.PropertyAccessExpression || exprNode.kind === ts.SyntaxKind.ElementAccessExpression) { // Get the type to the left of the call expression (i.e. what the function is being called on) const lhsExpr = exprNode.expression; const lhsExprType = this.checker.getTypeAtLocation(lhsExpr); if (lhsExprType.isClassOrInterface()) { // left-hand-side is an instance of a class, e.g. "instance.deprecatedMethod()" additionalMessage = `of class '${this.checker.typeToString(lhsExprType)}'`; } else if (ts.isCallExpression(lhsExpr)) { // left-hand-side is a function call, e.g. "function().deprecatedMethod()" // Use the (return) type of that function call additionalMessage = `of module '${this.checker.typeToString(lhsExprType)}'`; } else if (ts.isPropertyAccessExpression(exprNode)) { // left-hand-side is a module or namespace, e.g. "module.deprecatedMethod()" additionalMessage = `(${extractNamespace(exprNode)})`; } } else if (globalApiName) { additionalMessage = `(${globalApiName})`; } let propName; if (ts.isPropertyName(reportNode)) { propName = getPropertyNameText(reportNode); } propName ??= reportNode.getText(); this.#reporter.addMessage(MESSAGE.DEPRECATED_FUNCTION_CALL, { functionName: propName, additionalMessage, details: deprecationInfo.messageDetails, }, { node: reportNode, ui5TypeInfo: deprecationInfo.ui5TypeInfo, fix: this.getFix(node, deprecationInfo.ui5TypeInfo), }); if (propName === "attachInit" && this.hasQUnitFileExtension() && !VALID_TESTSUITE.test(this.sourceFile.fileName)) { this.#reportTestStarter(reportNode); } } #analyzeLibInitCall(node, exprNode) { const initArg = node?.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0]) && node.arguments[0]; let nodeToHighlight; if (!initArg) { nodeToHighlight = node; } else { const apiVersionNode = getPropertyAssignmentInObjectLiteralExpression("apiVersion", initArg); if (!apiVersionNode) { // No arguments or no 'apiVersion' property nodeToHighlight = node; } else if (!ts.isNumericLiteral(apiVersionNode.initializer) || // Must be a number, not a string apiVersionNode.initializer.text !== "2") { nodeToHighlight = apiVersionNode; } } if (nodeToHighlight) { let importedVarName; if (ts.isIdentifier(exprNode)) { importedVarName = exprNode.text; } else { importedVarName = exprNode.expression.getText() + ".init"; } this.#reporter.addMessage(MESSAGE.LIB_INIT_API_VERSION, { libInitFunction: importedVarName, }, { node: nodeToHighlight }); } if (initArg) { this.#analyzeLibInitDeprecatedLibs(initArg); } } #analyzeLibInitDeprecatedLibs(initArg) { const dependenciesNode = getPropertyAssignmentInObjectLiteralExpression("dependencies", initArg); if (!dependenciesNode || !ts.isPropertyAssignment(dependenciesNode) || !ts.isArrayLiteralExpression(dependenciesNode.initializer)) { return; } dependenciesNode.initializer.elements.forEach((dependency) => { if (!ts.isStringLiteralLike(dependency)) { // We won't be interested if the elements of the Array are not of type // StringLiteralLike, so we ignore such cases here (if such at all). return; } const curLibName = dependency.text; if (deprecatedLibraries.includes(curLibName)) { this.#reporter.addMessage(MESSAGE.DEPRECATED_LIBRARY, { libraryName: curLibName, }, { node: dependency }); } }); } #reportTestStarter(node) { if (!this.#hasTestStarterFindings) { this.#reporter.addMessage(MESSAGE.PREFER_TEST_STARTER, null, { node }); this.#hasTestStarterFindings = true; } } #extractXmlFromJs(callExpression, apiName) { // xmlCompiledResource is an XML file, so we don't need to do extraction here if (this.#metadata?.xmlCompiledResource) { return; } const options = callExpression.arguments[0]; if (!options || !ts.isObjectLiteralExpression(options)) { return; } let documentKind; switch (apiName) { case "View.create": case "XMLView.create": case "sap.ui.view": case "sap.ui.xmlview": documentKind = "view"; break; case "Fragment.load": case "sap.ui.fragment": case "sap.ui.xmlfragment": documentKind = "fragment"; break; default: throw new Error("Unexpected apiName"); } let definitionPropertyName; switch (apiName) { case "View.create":