Skip to content

Commit

Permalink
feat: Detect deprecations in ManagedObject metadata (#349)
Browse files Browse the repository at this point in the history
JIRA: CPOUI5FOUNDATION-858

Depends on: #358
  • Loading branch information
d3xter666 authored Oct 16, 2024
1 parent 77b796e commit 9cc1202
Show file tree
Hide file tree
Showing 10 changed files with 1,746 additions and 5 deletions.
1,057 changes: 1,057 additions & 0 deletions resources/api-extract.json

Large diffs are not rendered by default.

45 changes: 44 additions & 1 deletion scripts/metadataProvider/createMetadataInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ import MetadataProvider from "./MetadataProvider.js";
import {
forEachSymbol,
} from "@ui5-language-assistant/semantic-model";
import {
UI5Class,
UI5Enum,
UI5Namespace,
UI5Interface,
UI5Typedef,
UI5Function,
UI5SemanticModel,
} from "@ui5-language-assistant/semantic-model-types";
type ui5UnionType = UI5Class | UI5Enum | UI5Namespace | UI5Interface | UI5Typedef | UI5Function;
const hasFieldsProperty = function (type: unknown): type is UI5Class | UI5Enum | UI5Namespace {
return (type as UI5Class | UI5Enum | UI5Namespace).fields !== undefined;
};

export default async function createMetadataInfo(apiJsonsRoot: string, sapui5Version: string) {
const metadataProvider = new MetadataProvider();
Expand All @@ -25,10 +38,40 @@ export default async function createMetadataInfo(apiJsonsRoot: string, sapui5Ver
version: sapui5Version,
},
defaultAggregations,
deprecations: createDeprecationsInfo(semanticModel),
};

await writeFile(
new URL("../../resources/api-extract.json", import.meta.url),
JSON.stringify(apiExtract, null, 2)
JSON.stringify(apiExtract, null, 2) + "\n"
);
}

function createDeprecationsInfo(semanticModel: UI5SemanticModel) {
const deprecations: Record<string, Record<string, string>> = {};
for (const [modelName, model] of Object.entries(semanticModel)) {
if (typeof model !== "object" || Array.isArray(model)) {
continue;
}

deprecations[modelName] = deprecations[modelName] ?? {};

for (const [typeName, type] of Object.entries(model as Record<string, ui5UnionType>)) {
if (type?.deprecatedInfo?.isDeprecated) {
deprecations[modelName][typeName] =
(type.deprecatedInfo.since ? `(since ${type.deprecatedInfo.since}) ` : "") +
(type.deprecatedInfo.text ? `${type.deprecatedInfo.text}` : "");
}

if (hasFieldsProperty(type)) {
type.fields?.forEach((field) => {
if (field?.deprecatedInfo?.isDeprecated) {
deprecations[modelName][typeName + "." + field.name] = "deprecated";
}
});
}
}
}

return deprecations;
}
20 changes: 20 additions & 0 deletions src/linter/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export enum MESSAGE {
DEPRECATED_API_ACCESS,
DEPRECATED_BOOTSTRAP_PARAM,
DEPRECATED_CLASS,
DEPRECATED_INTERFACE,
DEPRECATED_TYPE,
DEPRECATED_COMPONENT,
DEPRECATED_DECLARATIVE_SUPPORT,
DEPRECATED_FUNCTION_CALL,
Expand Down Expand Up @@ -140,6 +142,24 @@ export const MESSAGE_INFO = {
details: ({details}: {details: string}) => details,
},

[MESSAGE.DEPRECATED_INTERFACE]: {
severity: LintMessageSeverity.Error,
ruleId: RULES["no-deprecated-api"],

message: ({interfaceName}: {interfaceName: string}) =>
`Use of deprecated interface '${interfaceName}'`,
details: ({details}: {details: string}) => details,
},

[MESSAGE.DEPRECATED_TYPE]: {
severity: LintMessageSeverity.Error,
ruleId: RULES["no-deprecated-api"],

message: ({typeName}: {typeName: string}) =>
`Use of deprecated type '${typeName}'`,
details: ({details}: {details: string}) => details,
},

[MESSAGE.DEPRECATED_COMPONENT]: {
severity: LintMessageSeverity.Error,
ruleId: RULES["no-deprecated-component"],
Expand Down
153 changes: 152 additions & 1 deletion src/linter/ui5Types/SourceFileLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {MESSAGE} from "../messages.js";
import analyzeComponentJson from "./asyncComponentFlags.js";
import {deprecatedLibraries} from "../../utils/deprecations.js";
import {getPropertyName} from "./utils.js";
import {taskStart} from "../../utils/perf.js";

const log = getLogger("linter:ui5Types:SourceFileLinter");

Expand Down Expand Up @@ -45,6 +46,7 @@ export default class SourceFileLinter {
#reportCoverage: boolean;
#messageDetails: boolean;
#dataTypes: Record<string, string>;
#apiExtract: Record<string, Record<string, Record<string, string>>>;
#manifestContent: string | undefined;
#fileName: string;
#isComponent: boolean;
Expand All @@ -53,7 +55,8 @@ export default class SourceFileLinter {
context: LinterContext, resourcePath: ResourcePath,
sourceFile: ts.SourceFile, sourceMap: string | undefined, checker: ts.TypeChecker,
reportCoverage: boolean | undefined = false, messageDetails: boolean | undefined = false,
dataTypes: Record<string, string> | undefined, manifestContent?: string
dataTypes: Record<string, string> | undefined, manifestContent?: string,
apiExtract?: Record<string, Record<string, Record<string, string>>>
) {
this.#resourcePath = resourcePath;
this.#sourceFile = sourceFile;
Expand All @@ -67,6 +70,7 @@ export default class SourceFileLinter {
this.#fileName = path.basename(resourcePath);
this.#isComponent = this.#fileName === "Component.js" || this.#fileName === "Component.ts";
this.#dataTypes = dataTypes ?? {};
this.#apiExtract = apiExtract ?? {};
}

// eslint-disable-next-line @typescript-eslint/require-await
Expand Down Expand Up @@ -120,12 +124,159 @@ export default class SourceFileLinter {
context: this.#context,
checker: this.#checker,
});
} else if (ts.isPropertyDeclaration(node) && node.name.getText() === "metadata") {
const visitMetadataNodes = (childNode: ts.Node) => {
if (ts.isPropertyAssignment(childNode)) { // Skip nodes out of interest
this.analyzeMetadataProperty(childNode.name.getText(), childNode);
}

ts.forEachChild(childNode, visitMetadataNodes);
};

if (this.isUi5controlMetadataNode(node)) {
ts.forEachChild(node, visitMetadataNodes);
}
}

// Traverse the whole AST from top to bottom
ts.forEachChild(node, this.#boundVisitNode);
}

isUi5controlMetadataNode(node: ts.PropertyAssignment | ts.PropertyDeclaration): boolean {
// Go up the hierarchy chain to find whether the class extends from "sap/ui/base/ManagedObject"
const isObjectMetadataAncestor = (node: ts.ClassDeclaration): boolean => {
return node?.heritageClauses?.flatMap((parentClasses: ts.HeritageClause) => {
return parentClasses.types.flatMap((parentClass) => {
const parentClassType = this.#checker.getTypeAtLocation(parentClass);

return parentClassType.symbol?.declarations?.flatMap((declaration) => {
if (ts.isClassDeclaration(declaration)) {
if (declaration.name?.getText() === "ManagedObject" &&
ts.isModuleDeclaration(declaration.parent.parent) &&
declaration.parent.parent.name?.text === "sap/ui/base/ManagedObject") {
return true;
} else {
return isObjectMetadataAncestor(declaration);
}
} else {
return false;
}
});
});
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
}).reduce((acc, cur) => cur || acc, false) ?? false;
};

const metadataFound = taskStart("isPropertyInMetadata", this.#resourcePath, true);

if (node.name.getText() !== "metadata") {
metadataFound();
return false;
}

let parentNode: ts.Node = node.parent;
while (parentNode && parentNode.kind !== ts.SyntaxKind.ClassDeclaration) {
parentNode = parentNode.parent;
}

if (!parentNode) {
metadataFound();
return false;
}

const result = isObjectMetadataAncestor(parentNode as ts.ClassDeclaration);
metadataFound();
return result;
}

analyzeMetadataProperty(type: string, node: ts.PropertyAssignment) {
const analyzeMetadataDone = taskStart(`analyzeMetadataProperty: ${type}`, this.#resourcePath, true);
if (type === "interfaces") {
const deprecatedInterfaces = this.#apiExtract.deprecations.interfaces;

if (ts.isArrayLiteralExpression(node.initializer)) {
node.initializer.elements.forEach((elem) => {
const interfaceName = (elem as ts.StringLiteral).text;

if (deprecatedInterfaces[interfaceName]) {
this.#reporter.addMessage(MESSAGE.DEPRECATED_INTERFACE, {
interfaceName: interfaceName,
details: deprecatedInterfaces[interfaceName],
}, elem);
}
});
}
} else if (type === "altTypes" && ts.isArrayLiteralExpression(node.initializer)) {
const deprecatedTypes = {
...this.#apiExtract.deprecations.enums,
...this.#apiExtract.deprecations.typedefs,
};
node.initializer.elements.forEach((element) => {
const nodeType = ts.isStringLiteral(element) ? element.text : "";

if (deprecatedTypes[nodeType]) {
this.#reporter.addMessage(MESSAGE.DEPRECATED_TYPE, {
typeName: nodeType,
details: deprecatedTypes[nodeType],
}, element);
}
});
} else if (type === "defaultValue") {
const deprecatedTypes = {
...this.#apiExtract.deprecations.enums,
...this.#apiExtract.deprecations.typedefs,
};
const defaultValueType = ts.isStringLiteral(node.initializer) ?
node.initializer.text :
"";

const typeNode = node.parent.properties.find((prop) => {
return ts.isPropertyAssignment(prop) && prop.name.getText() === "type";
});

const fullyQuantifiedName = (typeNode &&
ts.isPropertyAssignment(typeNode) &&
ts.isStringLiteral(typeNode.initializer)) ?
[typeNode.initializer.text, defaultValueType].join(".") :
"";

if (deprecatedTypes[fullyQuantifiedName]) {
this.#reporter.addMessage(MESSAGE.DEPRECATED_TYPE, {
typeName: defaultValueType,
details: deprecatedTypes[fullyQuantifiedName],
}, 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.isStringLiteral(node.initializer)) {
const deprecatedTypes = {
...this.#apiExtract.deprecations.enums,
...this.#apiExtract.deprecations.typedefs,
} as Record<string, string>;

// 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) => {
if (this.#apiExtract.deprecations.classes[nodeType]) {
this.#reporter.addMessage(MESSAGE.DEPRECATED_CLASS, {
className: nodeType,
details: this.#apiExtract.deprecations.classes[nodeType],
}, node.initializer);
} else if (deprecatedTypes[nodeType]) {
this.#reporter.addMessage(MESSAGE.DEPRECATED_TYPE, {
typeName: nodeType,
details: deprecatedTypes[nodeType],
}, node.initializer);
}
});
}
analyzeMetadataDone();
}

analyzeIdentifier(node: ts.Identifier) {
const type = this.#checker.getTypeAtLocation(node);
if (!type?.symbol || !this.isSymbolOfUi5OrThirdPartyType(type.symbol)) {
Expand Down
9 changes: 8 additions & 1 deletion src/linter/ui5Types/TypeLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export default class TypeChecker {
);
const dataTypes = JSON.parse(dataTypesFile) as Record<string, string>;

const apiExtractFile = await fs.readFile(
new URL("../../../resources/api-extract.json", import.meta.url),
{encoding: "utf-8"}
);
const apiExtract = JSON.parse(apiExtractFile) as
Record<string, Record<string, Record<string, string>>>;

const reportCoverage = this.#context.getReportCoverage();
const messageDetails = this.#context.getIncludeMessageDetails();
const typeCheckDone = taskStart("Linting all transpiled resources");
Expand All @@ -132,7 +139,7 @@ export default class TypeChecker {
this.#context, sourceFile.fileName,
sourceFile, sourceMap,
checker, reportCoverage, messageDetails, dataTypes,
manifestContent
manifestContent, apiExtract
);
await linter.lint();
linterDone();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
sap.ui.define(["sap/ui/core/Control", "sap/m/library", "mycustom/lib/FancyMultiPage"],
function (Control, library, FancyMultiPage) {
var DateTimeInputType = library.DateTimeInputType;
var FrameType = library.FrameType;
var FancyText = Control.extend("sap.fancy.Text", {
metadata: {
// sap.ui.commons.FormattedTextViewControl: deprecated
interfaces: ["sap.ui.commons.FormattedTextViewControl"],
library: "sap.fancy",
properties: {
text: {type: "string", group: "Data", defaultValue: "", bindable: "bindable"},
textShort: "sap.f.AvatarShape",
// sap.m.DateTimeInputType: deprecated, DateTimeInputType.DateTime: deprecated
textDirection: {type: "sap.m.DateTimeInputType", group: "Appearance", defaultValue: DateTimeInputType.DateTime},
// sap.m.DateTimeInputType: deprecated, "Date": deprecated
textDirectionB: {type: "sap.m.DateTimeInputType", group: "Appearance", defaultValue: "Date"},
// sap.m.FrameType: NOT deprecated, sap.m.FrameType.TwoThirds: deprecated
textAlign: {type: "sap.m.FrameType", group: "Appearance", defaultValue: FrameType.TwoThirds},
// sap.m.FrameType: NOT deprecated, "TwoThirds": deprecated
textAlignB: {type: "sap.m.FrameType", group: "Appearance", defaultValue: "TwoThirds"},
},
aggregations: {
// sap.f.Avatar: deprecated
myagg: {type: "sap.f.Avatar", multiple: false, visibility: "hiddenDeprecated"},
// sap.f.Avatar: deprecated
myaggShort: "sap.f.Avatar",
// sap.f.IllustratedMessageSize DataType: deprecated
tooltip: {type: "sap.ui.core.TooltipBase", altTypes: ["string", "sap.f.IllustratedMessageSize"], multiple: false},
beginColumnPages: {
type: "sap.ui.core.Control",
multiple: true,
forwarding: {
getter: "_getBeginColumn",
aggregation: "deprecatedPages",
},
},
},
associations: {
// sap.f.Avatar: deprecated
initialBeginColumnPage: {type: "sap.f.Avatar", multiple: false},
// sap.f.Avatar: deprecated
initialBeginColumnPageShort: "sap.f.Avatar",
},
events: {
eventA: {
parameters: {
layout: {
type: "sap.f.DynamicPageTitleArea", // deprecated
},
},
},
eventB: {
parameters: {
layout: {
type: "Promise<sap.f.DynamicPageTitleArea>", // deprecated
},
},
},
eventC: {
parameters: {
layout: "Promise<sap.f.AvatarShape>", // deprecated
newItems: {
type: "sap.m.P13nColumnsItem[]", // deprecated
},
},
},
},
},
});

return FancyText;
});
Loading

0 comments on commit 9cc1202

Please sign in to comment.