From 5a5af4c2a13239177515554a447b5053dee932b9 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Jan 2025 10:01:13 +0100 Subject: [PATCH 01/10] feat: Add Autofix capabilities JIRA: CPOUI5FOUNDATION-989 --- README.md | 10 + npm-shrinkwrap.json | 2 +- package.json | 6 +- src/autofix/autofix.ts | 405 ++++ src/autofix/solutions/noGlobals.ts | 133 ++ src/autofix/utils.ts | 127 ++ src/cli/base.ts | 8 + src/index.ts | 7 + src/linter/LinterContext.ts | 32 +- src/linter/binding/BindingLinter.ts | 1 + src/linter/lintWorkspace.ts | 59 +- src/linter/linter.ts | 6 +- src/linter/messages.ts | 3 +- src/linter/ui5Types/SourceFileLinter.ts | 68 +- .../ui5Types/amdTranspiler/pruneNode.ts | 3 + src/linter/xmlTemplate/Parser.ts | 1 + test/e2e/compare-ui5lint-fix-snapshots.ts | 23 + ...pshots.ts => compare-ui5lint-snapshots.ts} | 2 +- test/e2e/snapshots/autofix-e2e.ts.md | 1211 +++++++++++ test/e2e/snapshots/autofix-e2e.ts.snap | Bin 0 -> 7754 bytes test/e2e/snapshots/compare-snapshots.ts.md | 86 +- test/e2e/snapshots/compare-snapshots.ts.snap | Bin 4670 -> 5082 bytes .../compare-ui5lint-fix-snapshots.ts.md | 1840 +++++++++++++++++ .../compare-ui5lint-fix-snapshots.ts.snap | Bin 0 -> 12596 bytes .../snapshots/compare-ui5lint-snapshots.ts.md | 671 ++++++ .../compare-ui5lint-snapshots.ts.snap | Bin 0 -> 5090 bytes .../GlobalsExistingDefineTooManyArgs.js | 15 + ...lobalsExistingDefineWithAllExistingDeps.js | 8 + .../autofix/GlobalsExistingDefineWithDeps.js | 25 + ...alsExistingDefineWithDeps_ArrowFunction.js | 23 + ...DefineWithDeps_ArrowFunction_NoBrackets.js | 6 + ...tingDefineWithDeps_ExistingArgumentName.js | 6 + ...istingDefineWithDeps_FunctionExpression.js | 25 + ...balsExistingDefineWithDeps_SpecialNames.js | 12 + ...GlobalsExistingDefineWithTrailingCommas.js | 6 + .../GlobalsExistingDefineWithUnusedDeps.js | 6 + .../GlobalsExistingDefineWithoutDeps.js | 15 + ...ExistingDefineWithoutDeps_ArrowFunction.js | 15 + ...alsExistingDefineWithoutDeps_EmptyArray.js | 15 + ...ingDefineWithoutDeps_FunctionExpression.js | 17 + test/fixtures/autofix/GlobalsNoModules.js | 13 + .../autofix/GlobalsReturnComponentClass.js | 12 + test/fixtures/autofix/GlobalsTypeScript.ts | 9 + .../webapp/controller/Main.controller.js | 16 + test/lib/autofix/autofix.ts | 5 + test/lib/autofix/snapshots/autofix.ts.md | 1017 +++++++++ test/lib/autofix/snapshots/autofix.ts.snap | Bin 0 -> 4974 bytes test/lib/autofix/utils.ts | 38 + test/lib/cli/base.ts | 11 +- test/lib/index.ts | 7 + test/lib/linter/_linterHelper.ts | 52 +- test/lib/linter/snapshots/linter.ts.md | 372 +++- test/lib/linter/snapshots/linter.ts.snap | Bin 27772 -> 29725 bytes test/lib/snapshots/index.integration.ts.md | 86 +- test/lib/snapshots/index.integration.ts.snap | Bin 6181 -> 6583 bytes 55 files changed, 6459 insertions(+), 77 deletions(-) create mode 100644 src/autofix/autofix.ts create mode 100644 src/autofix/solutions/noGlobals.ts create mode 100644 src/autofix/utils.ts create mode 100644 test/e2e/compare-ui5lint-fix-snapshots.ts rename test/e2e/{compare-snapshots.ts => compare-ui5lint-snapshots.ts} (80%) create mode 100644 test/e2e/snapshots/autofix-e2e.ts.md create mode 100644 test/e2e/snapshots/autofix-e2e.ts.snap create mode 100644 test/e2e/snapshots/compare-ui5lint-fix-snapshots.ts.md create mode 100644 test/e2e/snapshots/compare-ui5lint-fix-snapshots.ts.snap create mode 100644 test/e2e/snapshots/compare-ui5lint-snapshots.ts.md create mode 100644 test/e2e/snapshots/compare-ui5lint-snapshots.ts.snap create mode 100644 test/fixtures/autofix/GlobalsExistingDefineTooManyArgs.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithAllExistingDeps.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithDeps.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithDeps_ArrowFunction.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithDeps_ArrowFunction_NoBrackets.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithDeps_ExistingArgumentName.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithDeps_FunctionExpression.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithDeps_SpecialNames.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithTrailingCommas.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithUnusedDeps.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithoutDeps.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithoutDeps_ArrowFunction.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithoutDeps_EmptyArray.js create mode 100644 test/fixtures/autofix/GlobalsExistingDefineWithoutDeps_FunctionExpression.js create mode 100644 test/fixtures/autofix/GlobalsNoModules.js create mode 100644 test/fixtures/autofix/GlobalsReturnComponentClass.js create mode 100644 test/fixtures/autofix/GlobalsTypeScript.ts create mode 100644 test/lib/autofix/autofix.ts create mode 100644 test/lib/autofix/snapshots/autofix.ts.md create mode 100644 test/lib/autofix/snapshots/autofix.ts.snap create mode 100644 test/lib/autofix/utils.ts diff --git a/README.md b/README.md index c6afd852f..b0e0c8a20 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Options](#options) - [`--details`](#--details) - [`--format`](#--format) + - [`--fix`](#--fix) - [`--ignore-pattern`](#--ignore-pattern) - [`--config`](#--config) - [`--ui5-config`](#--ui5-config) @@ -151,6 +152,15 @@ Choose the output format. Currently, `stylish` (default), `json` and `markdown` ui5lint --format json ``` +#### `--fix` + +Automatically fix linter findings + +**Example:** +```sh +ui5lint --fix +``` + #### `--ignore-pattern` Pattern/files that will be ignored during linting. Can also be defined in `ui5lint.config.js`. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index fdf24e997..92bce3d48 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -21,6 +21,7 @@ "globals": "^16.0.0", "he": "^1.2.0", "json-source-map": "^0.6.1", + "magic-string": "^0.30.17", "minimatch": "^10.0.1", "sax-wasm": "^3.0.5", "typescript": "^5.8.2", @@ -8359,7 +8360,6 @@ "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } diff --git a/package.json b/package.json index 808b44864..39261b564 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "prepare": "node ./.husky/skip.js || husky", "test": "npm run lint && npm run build-test && npm run coverage && npm run e2e && npm run depcheck && npm run check-licenses", "unit": "ava", - "e2e": "npm run build && npm run e2e:ui5lint && npm run e2e:test", - "e2e:ui5lint": "TEST_E2E_TMP=$PWD/test/tmp/e2e && npm run clean-test-tmp && mkdir -p $TEST_E2E_TMP && cd test/fixtures/linter/projects/com.ui5.troublesome.app && npm exec ui5lint -- --format=json > $TEST_E2E_TMP/ui5lint-results.json 2> $TEST_E2E_TMP/stderr.log || true", + "e2e": "npm run clean-test-tmp && npm run build && npm run e2e:ui5lint && npm run e2e:ui5lint-fix && npm run e2e:test", + "e2e:ui5lint": "TEST_E2E_TMP=$PWD/test/tmp/e2e && mkdir -p $TEST_E2E_TMP && cd test/fixtures/linter/projects/com.ui5.troublesome.app && npm exec ui5lint -- --format=json > $TEST_E2E_TMP/ui5lint-results.json 2> $TEST_E2E_TMP/stderr.log || true", + "e2e:ui5lint-fix": "TEST_E2E_TMP=$PWD/test/tmp/e2e && mkdir -p $TEST_E2E_TMP && cp -r test/fixtures/linter/projects/com.ui5.troublesome.app $TEST_E2E_TMP && cd $TEST_E2E_TMP/com.ui5.troublesome.app && npm exec ui5lint -- --fix --format=json > $TEST_E2E_TMP/ui5lint-results-fix.json 2> $TEST_E2E_TMP/stderr-fix.log || true", "e2e:test": "ava --config ava-e2e.config.js", "e2e:test-update-snapshots": "ava --config ava-e2e.config.js --update-snapshots", "unit-debug": "ava debug", @@ -81,6 +82,7 @@ "globals": "^16.0.0", "he": "^1.2.0", "json-source-map": "^0.6.1", + "magic-string": "^0.30.17", "minimatch": "^10.0.1", "sax-wasm": "^3.0.5", "typescript": "^5.8.2", diff --git a/src/autofix/autofix.ts b/src/autofix/autofix.ts new file mode 100644 index 000000000..930190592 --- /dev/null +++ b/src/autofix/autofix.ts @@ -0,0 +1,405 @@ +import ts from "typescript"; +import MagicString from "magic-string"; +import LinterContext, {RawLintMessage, ResourcePath} from "../linter/LinterContext.js"; +import {MESSAGE} from "../linter/messages.js"; +import {ModuleDeclaration} from "../linter/ui5Types/amdTranspiler/parseModuleDeclaration.js"; +import generateSolutionNoGlobals from "./solutions/noGlobals.js"; +import {collectModuleIdentifiers, getIdentifierForImport} from "./utils.js"; + +export interface AutofixResource { + content: string; + messages: RawLintMessage[]; +} + +export interface AutofixOptions { + rootDir: string; + namespace?: string; + resources: Map; + context: LinterContext; +} + +export enum ChangeAction { + INSERT = "insert", + REPLACE = "replace", + DELETE = "delete", +} + +export type ChangeSet = InsertChange | ReplaceChange | DeleteChange; + +interface AbstractChangeSet { + action: ChangeAction; + start: number; +} + +interface InsertChange extends AbstractChangeSet { + action: ChangeAction.INSERT; + value: string; +} + +interface ReplaceChange extends AbstractChangeSet { + action: ChangeAction.REPLACE; + end: number; + value: string; +} + +interface DeleteChange extends AbstractChangeSet { + action: ChangeAction.DELETE; + end: number; +} + +export type AutofixResult = Map; +type SourceFiles = Map; + +interface Position { + line: number; + column: number; + pos: number; +} +export interface GlobalPropertyAccessNodeInfo { + globalVariableName: string; + namespace: string; + moduleName: string; + exportName?: string; + propertyAccess?: string; + position: Position; + node?: ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression; +} + +export interface DeprecatedApiAccessNode { + apiName: string; + position: Position; + node?: ts.CallExpression | ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression; +} + +type ImportRequests = Map; + +// type ModuleDeclarationInfo = ExistingModuleDeclarationInfo | NewModuleDeclarationInfo; + +export interface ExistingModuleDeclarationInfo { + moduleDeclaration: ModuleDeclaration; + importRequests: ImportRequests; +} + +export interface NewModuleDeclarationInfo { + declareCall: ts.CallExpression; + requireCalls: Map; + importRequests: ImportRequests; + endPos?: number; +} + +function createCompilerHost(sourceFiles: SourceFiles): ts.CompilerHost { + return { + getSourceFile: (fileName) => sourceFiles.get(fileName), + writeFile: () => undefined, + getDefaultLibFileName: () => "lib.d.ts", + useCaseSensitiveFileNames: () => false, + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => "", + getNewLine: () => "\n", + fileExists: (fileName): boolean => sourceFiles.has(fileName), + readFile: () => "", + directoryExists: () => true, + getDirectories: () => [], + }; +} + +const compilerOptions: ts.CompilerOptions = { + checkJs: false, + allowJs: true, + skipLibCheck: true, + noCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + isolatedModules: true, + sourceMap: true, + suppressOutputPathCheck: true, + noLib: true, + noResolve: true, + allowNonTsExtensions: true, +}; + +function createProgram(inputFileNames: string[], host: ts.CompilerHost): ts.Program { + return ts.createProgram(inputFileNames, compilerOptions, host); +} + +function getJsErrors(code: string, resourcePath: string) { + const sourceFile = ts.createSourceFile( + resourcePath, + code, + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.JS + ); + + const host = createCompilerHost(new Map([[resourcePath, sourceFile]])); + const program = createProgram([resourcePath], host); + const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile); + + return diagnostics.filter(function (d) { + return d.file === sourceFile && d.category === ts.DiagnosticCategory.Error; + }); +} + +// eslint-disable-next-line @typescript-eslint/require-await +export default async function ({ + rootDir: _unused1, + namespace: _unused2, + resources, + context, +}: AutofixOptions): Promise { + const sourceFiles: SourceFiles = new Map(); + const resourcePaths = []; + for (const [resourcePath, resource] of resources) { + const sourceFile = ts.createSourceFile( + resourcePath, + resource.content, + { + languageVersion: ts.ScriptTarget.ES2022, + jsDocParsingMode: ts.JSDocParsingMode.ParseNone, + } + ); + sourceFiles.set(resourcePath, sourceFile); + resourcePaths.push(resourcePath); + } + + const compilerHost = createCompilerHost(sourceFiles); + const program = createProgram(resourcePaths, compilerHost); + + const checker = program.getTypeChecker(); + const res: AutofixResult = new Map(); + for (const [resourcePath, sourceFile] of sourceFiles) { + const newContent = applyFixes(checker, sourceFile, resourcePath, resources.get(resourcePath)!); + if (newContent) { + const jsErrors = getJsErrors(newContent, resourcePath); + if (jsErrors.length) { + context.addLintingMessage( + resourcePath, + MESSAGE.PARSING_ERROR, + { + message: "After applying autofix: " + jsErrors.map((d) => d.messageText as string).join(", "), + } + ); + } else { + res.set(resourcePath, newContent); + } + } + } + + return res; +} + +function applyFixes( + checker: ts.TypeChecker, sourceFile: ts.SourceFile, resourcePath: ResourcePath, + resource: AutofixResource +): string | undefined { + const {content} = resource; + + // Group messages by id + const messagesById = new Map(); + for (const msg of resource.messages) { + if (!messagesById.has(msg.id)) { + messagesById.set(msg.id, []); + } + messagesById.get(msg.id)!.push(msg); + } + + const changeSet: ChangeSet[] = []; + let existingModuleDeclarations = new Map(); + if (messagesById.has(MESSAGE.NO_GLOBALS)) { + existingModuleDeclarations = generateSolutionNoGlobals( + checker, sourceFile, content, + messagesById.get(MESSAGE.NO_GLOBALS) as RawLintMessage[], + changeSet, []); + } + + for (const [defineCall, moduleDeclarationInfo] of existingModuleDeclarations) { + addDependencies(defineCall, moduleDeclarationInfo, changeSet); + } + + if (changeSet.length === 0) { + // No modifications needed + return undefined; + } + return applyChanges(content, changeSet); +} + +function addDependencies( + defineCall: ts.CallExpression, moduleDeclarationInfo: ExistingModuleDeclarationInfo, + changeSet: ChangeSet[] +) { + const {moduleDeclaration, importRequests} = moduleDeclarationInfo; + + if (importRequests.size === 0) { + return; + } + + const declaredIdentifiers = collectModuleIdentifiers(moduleDeclaration.factory); + + const defineCallArgs = defineCall.arguments; + const existingImportModules = defineCall.arguments && ts.isArrayLiteralExpression(defineCallArgs[0]) ? + defineCallArgs[0].elements.map((el) => ts.isStringLiteralLike(el) ? el.text : "") : + []; + + if (!ts.isFunctionLike(moduleDeclaration.factory)) { + throw new Error("Invalid factory function"); + } + const existingIdentifiers = moduleDeclaration.factory + .parameters.map((param: ts.ParameterDeclaration) => (param.name as ts.Identifier).text ?? "__destructured__"); + const existingIdentifiersLength = existingIdentifiers.length; + + const imports = [...importRequests.keys()]; + + const identifiersForExistingImports: string[] = []; + let existingIdentifiersCut = 0; + existingImportModules.forEach((existingModule, index) => { + const indexOf = imports.indexOf(existingModule); + const identifierName = existingIdentifiers[index] || + getIdentifierForImport(existingModule, declaredIdentifiers); + identifiersForExistingImports.push(identifierName); + if (indexOf !== -1 && + // Destructuring + !(indexOf === index && existingIdentifiers[index] === "__destructured__")) { + // If there are defined dependencies, but identifiers for them are missing, + // and those identifiers are needed in the code, then we need to find out + // up to which index we need to build identifiers and cut the rest. + existingIdentifiersCut = index > existingIdentifiersCut ? (index + 1) : existingIdentifiersCut; + imports.splice(indexOf, 1); + importRequests.get(existingModule)!.identifier = identifierName; + } + }); + + // Cut identifiers that are already there + identifiersForExistingImports.splice(existingIdentifiersCut); + + const dependencies = imports.map((i) => `"${i}"`); + const identifiers = [ + ...identifiersForExistingImports, + ...imports.map((i) => { + const identifier = getIdentifierForImport(i, declaredIdentifiers); + importRequests.get(i)!.identifier = identifier; + return identifier; + })]; + + if (dependencies.length) { + // Add dependencies + if (moduleDeclaration.dependencies) { + const depsNode = defineCall.arguments[0]; + const depElementNodes = depsNode && ts.isArrayLiteralExpression(depsNode) ? depsNode.elements : []; + const insertAfterElement = depElementNodes[existingIdentifiersLength - 1] ?? + depElementNodes[depElementNodes.length - 1]; + + if (insertAfterElement) { + changeSet.push({ + action: ChangeAction.INSERT, + start: insertAfterElement.getEnd(), + value: (existingImportModules.length ? ", " : "") + dependencies.join(", "), + }); + } else { + changeSet.push({ + action: ChangeAction.REPLACE, + start: depsNode.getFullStart(), + end: depsNode.getEnd(), + value: `[${dependencies.join(", ")}]`, + }); + } + } else { + changeSet.push({ + action: ChangeAction.INSERT, + start: defineCall.arguments[0].getFullStart(), + value: `[${dependencies.join(", ")}], `, + }); + } + } + + if (identifiers.length) { + const closeParenToken = moduleDeclaration.factory.getChildren() + .find((c) => c.kind === ts.SyntaxKind.CloseParenToken); + // Factory arguments + const syntaxList = moduleDeclaration.factory.getChildren() + .find((c) => c.kind === ts.SyntaxKind.SyntaxList); + if (!syntaxList) { + throw new Error("Invalid factory syntax"); + } + + // Patch factory arguments + const value = (existingIdentifiersLength ? ", " : "") + identifiers.join(", "); + if (!closeParenToken) { + changeSet.push({ + action: ChangeAction.INSERT, + start: syntaxList.getStart(), + value: "(", + }); + changeSet.push({ + action: ChangeAction.INSERT, + start: syntaxList.getEnd(), + value: `${value})`, + }); + } else { + let start = syntaxList.getEnd(); + + // Existing trailing comma: Insert new args before it, to keep the trailing comma + const lastSyntaxListChild = syntaxList.getChildAt(syntaxList.getChildCount() - 1); + if (lastSyntaxListChild?.kind === ts.SyntaxKind.CommaToken) { + start = lastSyntaxListChild.getStart(); + } + + changeSet.push({ + action: ChangeAction.INSERT, + start, + value, + }); + } + } + + // Patch identifiers + patchIdentifiers(importRequests, changeSet); +} + +function patchIdentifiers(importRequests: ImportRequests, changeSet: ChangeSet[]) { + for (const {nodeInfos, identifier} of importRequests.values()) { + if (!identifier) { + throw new Error("No identifier found for import"); + } + + for (const nodeInfo of nodeInfos) { + let node: ts.Node = nodeInfo.node!; + + if ("namespace" in nodeInfo && nodeInfo.namespace === "sap.ui.getCore") { + node = node.parent; + } + const nodeStart = node.getStart(); + const nodeEnd = node.getEnd(); + const nodeReplacement = `${identifier}`; + + changeSet.push({ + action: ChangeAction.REPLACE, + start: nodeStart, + end: nodeEnd, + value: nodeReplacement, + }); + } + } +} + +function applyChanges(content: string, changeSet: ChangeSet[]): string { + changeSet.sort((a, b) => a.start - b.start); + const s = new MagicString(content); + + for (const change of changeSet) { + switch (change.action) { + case ChangeAction.INSERT: + s.appendRight(change.start, change.value); + break; + case ChangeAction.REPLACE: + s.update(change.start, change.end, change.value); + break; + case ChangeAction.DELETE: + s.remove(change.start, change.end); + break; + } + } + return s.toString(); +} diff --git a/src/autofix/solutions/noGlobals.ts b/src/autofix/solutions/noGlobals.ts new file mode 100644 index 000000000..b80ae0ecf --- /dev/null +++ b/src/autofix/solutions/noGlobals.ts @@ -0,0 +1,133 @@ +import ts from "typescript"; +import type {RawLintMessage} from "../../linter/LinterContext.js"; +import {MESSAGE} from "../../linter/messages.js"; +import type { + ChangeSet, + ExistingModuleDeclarationInfo, + GlobalPropertyAccessNodeInfo, + NewModuleDeclarationInfo, +} from "../autofix.js"; +import {findGreatestAccessExpression, matchPropertyAccessExpression} from "../utils.js"; +import parseModuleDeclaration from "../../linter/ui5Types/amdTranspiler/parseModuleDeclaration.js"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("linter:autofix:NoGlobals"); + +export default function generateSolutionNoGlobals( + checker: ts.TypeChecker, sourceFile: ts.SourceFile, content: string, + messages: RawLintMessage[], + changeSet: ChangeSet[], newModuleDeclarations: NewModuleDeclarationInfo[] +) { + // Collect all global property access nodes + const affectedNodesInfo = new Set(); + for (const msg of messages) { + if (!msg.position) { + throw new Error(`Unable to produce solution for message without position`); + } + if (!msg.args.fixHints.moduleName) { + // Skip global access without module name + continue; + } + // TypeScript lines and columns are 0-based + const line = msg.position.line - 1; + const column = msg.position.column - 1; + const pos = sourceFile.getPositionOfLineAndCharacter(line, column); + affectedNodesInfo.add({ + globalVariableName: msg.args.variableName, + namespace: msg.args.namespace, + moduleName: msg.args.fixHints.moduleName, + exportName: msg.args.fixHints.exportName, + propertyAccess: msg.args.fixHints.propertyAccess, + position: { + line, + column, + pos, + }, + }); + } + + const sapUiDefineCalls: ts.CallExpression[] = []; + function visitNode(node: ts.Node) { + for (const nodeInfo of affectedNodesInfo) { + if (node.getStart() === nodeInfo.position.pos) { + if (!ts.isIdentifier(node)) { + continue; + // throw new Error(`Expected node to be an Identifier but got ${ts.SyntaxKind[node.kind]}`); + } + nodeInfo.node = findGreatestAccessExpression(node, nodeInfo.propertyAccess); + } + } + + if (ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression)) { + if (matchPropertyAccessExpression(node.expression, "sap.ui.define")) { + sapUiDefineCalls.push(node); + } + } + ts.forEachChild(node, visitNode); + } + ts.forEachChild(sourceFile, visitNode); + for (const nodeInfo of affectedNodesInfo) { + if (!nodeInfo.node) { + throw new Error(`Unable to find node for ${nodeInfo.globalVariableName}`); + } + } + + const moduleDeclarations = new Map(); + + for (const nodeInfo of affectedNodesInfo) { + const {moduleName, position} = nodeInfo; + // Find relevant sap.ui.define call + let defineCall: ts.CallExpression | undefined | null; + if (sapUiDefineCalls.length === 1) { + defineCall = sapUiDefineCalls[0]; + } else if (sapUiDefineCalls.length > 1) { + for (const sapUiDefineCall of sapUiDefineCalls) { + if (sapUiDefineCall.getStart() < position.pos) { + defineCall = sapUiDefineCall; + } + } + } + if (defineCall === undefined) { + defineCall = null; + } + let moduleDeclaration; + if (defineCall) { + if (!moduleDeclarations.has(defineCall)) { + try { + moduleDeclarations.set(defineCall, { + moduleDeclaration: parseModuleDeclaration(defineCall.arguments, checker), + importRequests: new Map(), + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.verbose(`Failed to autofix ${moduleName} in sap.ui.define ` + + `call in ${sourceFile.fileName}: ${errorMessage}`); + } + } + moduleDeclaration = moduleDeclarations.get(defineCall)!; + } else { + if (!newModuleDeclarations.length) { + // throw new Error(`TODO: Implement handling for global access without module declaration`); + } + for (const decl of newModuleDeclarations) { + if (position.pos > decl.declareCall.getStart()) { + moduleDeclaration = decl; + } else { + break; + } + } + } + if (!moduleDeclaration) { + // throw new Error(`TODO: Implement handling for global access without module declaration`); + } + if (moduleDeclaration && !moduleDeclaration.importRequests.has(moduleName)) { + moduleDeclaration.importRequests.set(moduleName, { + nodeInfos: [], + }); + } + moduleDeclaration?.importRequests.get(moduleName)!.nodeInfos.push(nodeInfo); + } + + return moduleDeclarations; +} diff --git a/src/autofix/utils.ts b/src/autofix/utils.ts new file mode 100644 index 000000000..7cc8a80dd --- /dev/null +++ b/src/autofix/utils.ts @@ -0,0 +1,127 @@ +import ts from "typescript"; +import {getPropertyNameText} from "../linter/ui5Types/utils.js"; + +export function findGreatestAccessExpression(node: ts.Identifier, matchPropertyAccess?: string): + ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression { + type Candidate = ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression; + let scanNode: Candidate = node; + let propertyAccessChain: string[] = []; + if (matchPropertyAccess) { + propertyAccessChain = matchPropertyAccess.split("."); + if (node.text !== "window") { + const firstPropAccess = propertyAccessChain.shift(); + if (node.text !== firstPropAccess) { + throw new Error(`Expected node to be ${firstPropAccess} but got ${node.getText()}`); + } + } + } + while (ts.isPropertyAccessExpression(scanNode.parent) || ts.isElementAccessExpression(scanNode.parent)) { + scanNode = scanNode.parent; + if (matchPropertyAccess) { + const nextPropertyAccess = propertyAccessChain.shift(); + + let propName; + if (ts.isPropertyAccessExpression(scanNode)) { + propName = getPropertyNameText(scanNode.name); + } else { + if ( + ts.isStringLiteralLike(scanNode.argumentExpression) || + ts.isNumericLiteral(scanNode.argumentExpression) + ) { + propName = scanNode.argumentExpression.text; + } else { + propName = scanNode.argumentExpression.getText(); + } + } + if (propName !== nextPropertyAccess) { + throw new Error(`Expected node to be ${nextPropertyAccess} but got ${propName}`); + } + if (!propertyAccessChain.length) { + return scanNode; + } + } + } + return scanNode; +} + +export function matchPropertyAccessExpression(node: ts.PropertyAccessExpression, match: string): boolean { + const propAccessChain: string[] = []; + propAccessChain.push(node.expression.getText()); + + let scanNode: ts.Node = node; + while (ts.isPropertyAccessExpression(scanNode)) { + propAccessChain.push(scanNode.name.getText()); + scanNode = scanNode.parent; + } + return propAccessChain.join(".") === match; +} + +export function getIdentifierForImport(importName: string, existingIdentifiers?: Set): string { + const parts = importName.split("/"); + const identifier = parts[parts.length - 1]; + if (identifier === "jquery") { + return "jQuery"; + } + if (identifier === "library") { + const potentialLibraryName = parts[parts.length - 2]; + + // Relative imports contain a dot and should not be mistaken for a library name + if (!potentialLibraryName.includes(".")) { + return potentialLibraryName + "Library"; + } else { + return identifier; + } + } + + let modifiedIdentifier = identifier; + let counter = 1; + while (existingIdentifiers?.has(modifiedIdentifier)) { + modifiedIdentifier = `${identifier}${counter}`; + counter++; + } + + return camelize(modifiedIdentifier); +} + +export function collectModuleIdentifiers(moduleDeclaration: ts.Node) { + const declaredIdentifiers = new Set(); + const extractDestructIdentifiers = (name: ts.BindingName, identifiers: Set) => { + if (ts.isIdentifier(name)) { + identifiers.add(name.text); + } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + for (const element of name.elements) { + if (ts.isBindingElement(element)) { + extractDestructIdentifiers(element.name, identifiers); + } + } + } + }; + const collectIdentifiers = (node: ts.Node) => { + if ( + ts.isVariableDeclaration(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) + ) { + if (node.name && ts.isIdentifier(node.name)) { + declaredIdentifiers.add(node.name.text); + } + } + + if (ts.isParameter(node) || ts.isVariableDeclaration(node)) { + extractDestructIdentifiers(node.name, declaredIdentifiers); + } + + ts.forEachChild(node, collectIdentifiers); + }; + + ts.forEachChild(moduleDeclaration, collectIdentifiers); + + return declaredIdentifiers; +} + +// Camelize a string by replacing invalid identifier characters +function camelize(str: string): string { + return str.replace(/[^\p{ID_Start}\p{ID_Continue}]+([\p{ID_Start}\p{ID_Continue}])/gu, (_match, nextChar) => { + return typeof nextChar === "string" ? nextChar.toUpperCase() : ""; + }); +} diff --git a/src/cli/base.ts b/src/cli/base.ts index 742d3ae57..386e37398 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -17,6 +17,7 @@ export interface LinterArg { filePaths?: string[]; ignorePattern?: string[]; details: boolean; + fix: boolean; format: string; config?: string; ui5Config?: string; @@ -71,6 +72,11 @@ const lintCommand: FixedCommandModule = { type: "boolean", default: false, }) + .option("fix", { + describe: "Automatically fix linter findings", + type: "boolean", + default: false, + }) .option("loglevel", { alias: "log-level", describe: "Set the logging level", @@ -137,6 +143,7 @@ async function handleLint(argv: ArgumentsCamelCase) { coverage, ignorePattern: ignorePatterns, details, + fix, format, config, ui5Config, @@ -158,6 +165,7 @@ async function handleLint(argv: ArgumentsCamelCase) { filePatterns, coverage: reportCoverage, details, + fix, config, ui5Config, }); diff --git a/src/index.ts b/src/index.ts index 66e6d201c..acca3a12a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,11 @@ export interface UI5LinterOptions { * @default false */ details?: boolean; + /** + * Automatically fix linter findings + * @default false + */ + fix?: boolean; /** * Path to a ui5lint.config.(cjs|mjs|js) file */ @@ -64,6 +69,7 @@ export class UI5LinterEngine { filePatterns, ignorePatterns = [], details = false, + fix = false, config, noConfig, coverage = false, @@ -78,6 +84,7 @@ export class UI5LinterEngine { ignorePatterns, coverage, details, + fix, configPath: config, noConfig, ui5Config, diff --git a/src/linter/LinterContext.ts b/src/linter/LinterContext.ts index 67d5800d3..fa2957513 100644 --- a/src/linter/LinterContext.ts +++ b/src/linter/LinterContext.ts @@ -21,6 +21,11 @@ export interface LintResult { warningCount: number; } +export interface RawLintResult { + filePath: FilePath; + rawMessages: RawLintMessage[]; +} + export interface RawLintMessage { id: M; args: MessageArgs[M]; @@ -64,19 +69,13 @@ export interface LinterOptions { ignorePatterns?: FilePattern[]; coverage?: boolean; details?: boolean; + fix?: boolean; configPath?: string; noConfig?: boolean; ui5Config?: string | object; namespace?: string; } -export interface FSToVirtualPathOptions { - relFsBasePath: string; - virBasePath: string; - relFsBasePathTest?: string; - virBasePathTest?: string; -}; - export interface LinterParameters { workspace: AbstractAdapter; filePathsWorkspace: AbstractAdapter; @@ -371,4 +370,23 @@ export default class LinterContext { return lintResults; } + + generateRawLintResults(): RawLintResult[] { + const rawLintResults: RawLintResult[] = []; + let resourcePaths; + if (this.#reportCoverage) { + resourcePaths = new Set([...this.#rawMessages.keys(), ...this.#coverageInfo.keys()]).values(); + } else { + resourcePaths = this.#rawMessages.keys(); + } + + for (const resourcePath of resourcePaths) { + rawLintResults.push({ + filePath: resourcePath, + rawMessages: this.#getFilteredMessages(resourcePath), + }); + } + + return rawLintResults; + } } diff --git a/src/linter/binding/BindingLinter.ts b/src/linter/binding/BindingLinter.ts index 568abff38..783561a2f 100644 --- a/src/linter/binding/BindingLinter.ts +++ b/src/linter/binding/BindingLinter.ts @@ -157,6 +157,7 @@ export default class BindingLinter { this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_GLOBALS, { variableName, namespace: ref, + fixHints: {}, }, position); } diff --git a/src/linter/lintWorkspace.ts b/src/linter/lintWorkspace.ts index 3daffb8a8..751fc5108 100644 --- a/src/linter/lintWorkspace.ts +++ b/src/linter/lintWorkspace.ts @@ -8,21 +8,74 @@ import lintFileTypes from "./fileTypes/linter.js"; import {taskStart} from "../utils/perf.js"; import TypeLinter from "./ui5Types/TypeLinter.js"; import LinterContext, {LintResult, LinterParameters, LinterOptions} from "./LinterContext.js"; -import {createReader} from "@ui5/fs/resourceFactory"; +import {createReader, createResource} from "@ui5/fs/resourceFactory"; import {mergeIgnorePatterns, resolveReader} from "./linter.js"; import {UI5LintConfigType} from "../utils/ConfigManager.js"; import type SharedLanguageService from "./ui5Types/SharedLanguageService.js"; -import {FSToVirtualPathOptions} from "../utils/virtualPathToFilePath.js"; +import autofix, {AutofixResource} from "../autofix/autofix.js"; +import {writeFile} from "node:fs/promises"; +import {FSToVirtualPathOptions, transformVirtualPathToFilePath} from "../utils/virtualPathToFilePath.js"; export default async function lintWorkspace( workspace: AbstractAdapter, filePathsWorkspace: AbstractAdapter, options: LinterOptions & FSToVirtualPathOptions, config: UI5LintConfigType, patternsMatch: Set, sharedLanguageService: SharedLanguageService ): Promise { - const context = await runLintWorkspace( + let context = await runLintWorkspace( workspace, filePathsWorkspace, options, config, patternsMatch, sharedLanguageService ); + if (options.fix) { + const rawLintResults = context.generateRawLintResults(); + const rootReader = context.getRootReader(); + + const autofixResources = new Map(); + for (const {filePath, rawMessages} of rawLintResults) { + // FIXME: handle this the same way as we already do for the general results + let resource = await workspace.byPath(filePath); + if (!resource) { + resource = await rootReader.byPath(filePath); + } + const content = await resource.getString(); + autofixResources.set(filePath, { + content, + messages: rawMessages, + }); + } + + const autofixResult = await autofix({ + rootDir: options.rootDir, + namespace: options.namespace, + resources: autofixResources, + context, + }); + + if (autofixResult.size > 0) { + for (const [filePath, content] of autofixResult.entries()) { + const newResource = createResource({ + path: filePath, + string: content, + }); + await workspace.write(newResource); + await filePathsWorkspace.write(newResource); + } + + // Run lint again after fixes are applied + context = await runLintWorkspace( + workspace, filePathsWorkspace, options, config, patternsMatch, sharedLanguageService + ); + + // Update fixed files on the filesystem + if (!process.env.UI5LINT_FIX_DRY_RUN) { + const autofixFiles = Array.from(autofixResult.entries()); + await Promise.all(autofixFiles.map(async ([filePath, content]) => { + const realFilePath = transformVirtualPathToFilePath(filePath, options); + await writeFile(realFilePath, content); + })); + } + } + } + return context.generateLintResults(); } diff --git a/src/linter/linter.ts b/src/linter/linter.ts index 5bb86872e..4fc357455 100644 --- a/src/linter/linter.ts +++ b/src/linter/linter.ts @@ -13,7 +13,7 @@ import type SharedLanguageService from "./ui5Types/SharedLanguageService.js"; import {FSToVirtualPathOptions, transformVirtualPathToFilePath} from "../utils/virtualPathToFilePath.js"; export async function lintProject({ - rootDir, filePatterns, ignorePatterns, coverage, details, configPath, ui5Config, noConfig, + rootDir, filePatterns, ignorePatterns, coverage, details, fix, configPath, ui5Config, noConfig, }: LinterOptions, sharedLanguageService: SharedLanguageService): Promise { if (!path.isAbsolute(rootDir)) { throw new Error(`rootDir must be an absolute path. Received: ${rootDir}`); @@ -71,6 +71,7 @@ export async function lintProject({ ignorePatterns, coverage, details, + fix, configPath, noConfig, ui5Config, @@ -89,7 +90,7 @@ export async function lintProject({ } export async function lintFile({ - rootDir, filePatterns, ignorePatterns, namespace, coverage, details, configPath, noConfig, + rootDir, filePatterns, ignorePatterns, namespace, coverage, details, fix, configPath, noConfig, }: LinterOptions, sharedLanguageService: SharedLanguageService ): Promise { let config: UI5LintConfigType = {}; @@ -111,6 +112,7 @@ export async function lintFile({ ignorePatterns, coverage, details, + fix, configPath, relFsBasePath: "", virBasePath, diff --git a/src/linter/messages.ts b/src/linter/messages.ts index cb1a6a699..9094d8884 100644 --- a/src/linter/messages.ts +++ b/src/linter/messages.ts @@ -437,7 +437,8 @@ export const MESSAGE_INFO = { message: ({variableName, namespace}: {variableName: string; namespace: string}) => `Access of global variable '${variableName}' (${namespace})`, - details: () => + details: ({fixHints: {moduleName: _unused1, exportName: _unused2, propertyAccess: _unused3}, + }: {fixHints: {moduleName?: string; exportName?: string; propertyAccess?: string}}) => `Do not use global variables to access UI5 modules or APIs. ` + `{@link topic:28fcd55b04654977b63dacbee0552712 See Best Practices for Developers}`, }, diff --git a/src/linter/ui5Types/SourceFileLinter.ts b/src/linter/ui5Types/SourceFileLinter.ts index 070bb61a3..e8d505ae5 100644 --- a/src/linter/ui5Types/SourceFileLinter.ts +++ b/src/linter/ui5Types/SourceFileLinter.ts @@ -1413,6 +1413,7 @@ export default class SourceFileLinter { this.#reporter.addMessage(MESSAGE.NO_GLOBALS, { variableName: node.text, namespace: moduleName, + fixHints: {}, }, node); } } @@ -1605,9 +1606,16 @@ export default class SourceFileLinter { if (symbol && this.isSymbolOfUi5OrThirdPartyType(symbol) && !((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && this.isAllowedPropertyAccess(node))) { + const namespace = this.extractNamespace((node as ts.PropertyAccessExpression)); + const {moduleName, exportName, propertyAccess} = this.getImportFromGlobal(namespace); this.#reporter.addMessage(MESSAGE.NO_GLOBALS, { variableName: symbol.getName(), - namespace: this.extractNamespace((node as ts.PropertyAccessExpression)), + namespace, + fixHints: { + moduleName, + exportName, + propertyAccess, // TODO: Provide end position instead? + }, }, node); } } @@ -1795,4 +1803,62 @@ export default class SourceFileLinter { hasQUnitFileExtension() { return QUNIT_FILE_EXTENSION.test(this.sourceFile.fileName); } + + findModuleForName(moduleName: string): ts.Symbol | undefined { + const moduleSymbol = this.ambientModuleCache.getModule(moduleName); + + if (!moduleSymbol) { + return; + } + const declarations = moduleSymbol.getDeclarations(); + if (!declarations) { + throw new Error(`Could not find declarations for module: ${moduleName}`); + } + for (const decl of declarations) { + const sourceFile = decl.getSourceFile(); + if (isSourceFileOfTypeScriptLib(sourceFile)) { + // Ignore any non-UI5 symbols + return; + } + if (isSourceFileOfPseudoModuleType(sourceFile)) { + // Ignore pseudo modules, we rather find them via probing for the library module + return; + } + } + return moduleSymbol; + } + + getImportFromGlobal(namespace: string): {moduleName?: string; exportName?: string; propertyAccess?: string} { + if (namespace === "jQuery") { + return {moduleName: "sap/ui/thirdparty/jquery"}; + } + namespace = namespace.replace(/^(?:window|globalThis|self)./, ""); + let moduleSymbol; + const parts = namespace.split("."); + const searchStack = [...parts]; + let exportName; + while (!moduleSymbol && searchStack.length) { + const moduleName = searchStack.join("/"); + moduleSymbol = this.findModuleForName(moduleName); + if (!moduleSymbol) { + const libraryModuleName = `${moduleName}/library`; + moduleSymbol = this.findModuleForName(libraryModuleName); + if (moduleSymbol) { + exportName = parts[searchStack.length]; + if (exportName && !moduleSymbol.exports?.has(exportName as ts.__String)) { + // throw new Error(`Could not find export ${exportName} in module: ${namespace}`); + return {}; + } + return {moduleName: libraryModuleName, exportName, propertyAccess: searchStack.join(".")}; + } + } + if (!moduleSymbol) { + searchStack.pop(); + } + } + if (!searchStack.length) { + return {}; + } + return {moduleName: searchStack.join("/"), exportName, propertyAccess: searchStack.join(".")}; + } } diff --git a/src/linter/ui5Types/amdTranspiler/pruneNode.ts b/src/linter/ui5Types/amdTranspiler/pruneNode.ts index e704d49dc..aebe8b726 100644 --- a/src/linter/ui5Types/amdTranspiler/pruneNode.ts +++ b/src/linter/ui5Types/amdTranspiler/pruneNode.ts @@ -21,11 +21,13 @@ export class UnsafeNodeRemoval extends Error { */ export default function (node: ts.Node) { let nodeToRemove: ts.Node | undefined = node; + let greatestRemovableNode: ts.Node | undefined = undefined; try { while (nodeToRemove) { // Attempt to prune the node, if the parent can exist without it if (pruneNode(nodeToRemove)) { nodeToRemove = nodeToRemove.parent; + greatestRemovableNode = nodeToRemove; } else { nodeToRemove = undefined; } @@ -38,6 +40,7 @@ export default function (node: ts.Node) { } throw err; } + return greatestRemovableNode; } /** diff --git a/src/linter/xmlTemplate/Parser.ts b/src/linter/xmlTemplate/Parser.ts index 1dd727e91..1e427b846 100644 --- a/src/linter/xmlTemplate/Parser.ts +++ b/src/linter/xmlTemplate/Parser.ts @@ -651,6 +651,7 @@ export default class Parser { this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_GLOBALS, { variableName, namespace: functionName, + fixHints: {}, }, position); } }); diff --git a/test/e2e/compare-ui5lint-fix-snapshots.ts b/test/e2e/compare-ui5lint-fix-snapshots.ts new file mode 100644 index 000000000..690b6f253 --- /dev/null +++ b/test/e2e/compare-ui5lint-fix-snapshots.ts @@ -0,0 +1,23 @@ +import test from "ava"; +import {readdir, readFile} from "node:fs/promises"; +import path from "node:path"; + +const E2E_DIR_URL = new URL("../tmp/e2e/", import.meta.url); +const APP_DIR_URL = new URL("../tmp/e2e/com.ui5.troublesome.app", import.meta.url); + +test.serial("Compare 'ui5lint --fix' com.ui5.troublesome.app result snapshots", async (t) => { + const stderr = await readFile(new URL("stderr-fix.log", E2E_DIR_URL), {encoding: "utf-8"}); + t.snapshot(stderr); + const results = JSON.parse(await readFile(new URL("ui5lint-results-fix.json", E2E_DIR_URL), {encoding: "utf-8"})); + t.snapshot(results); + + const projectFiles = (await readdir(APP_DIR_URL, {withFileTypes: true, recursive: true})) + .filter((dirEntries) => { + return dirEntries.isFile() && dirEntries.name !== ".DS_Store"; + }); + + for (const file of projectFiles) { + const content = await readFile(path.join(file.path, file.name), {encoding: "utf-8"}); + t.snapshot(`${file.name}:\n${content}`); + } +}); diff --git a/test/e2e/compare-snapshots.ts b/test/e2e/compare-ui5lint-snapshots.ts similarity index 80% rename from test/e2e/compare-snapshots.ts rename to test/e2e/compare-ui5lint-snapshots.ts index 185023850..0699d35ea 100644 --- a/test/e2e/compare-snapshots.ts +++ b/test/e2e/compare-ui5lint-snapshots.ts @@ -3,7 +3,7 @@ import {readFile} from "node:fs/promises"; const E2E_DIR_URL = new URL("../tmp/e2e/", import.meta.url); -test.serial("Compare com.ui5.troublesome.app result snapshots", async (t) => { +test.serial("Compare 'ui5lint' com.ui5.troublesome.app result snapshots", async (t) => { const stderr = await readFile(new URL("stderr.log", E2E_DIR_URL), {encoding: "utf-8"}); t.snapshot(stderr); const results = JSON.parse(await readFile(new URL("ui5lint-results.json", E2E_DIR_URL), {encoding: "utf-8"})); diff --git a/test/e2e/snapshots/autofix-e2e.ts.md b/test/e2e/snapshots/autofix-e2e.ts.md new file mode 100644 index 000000000..865008035 --- /dev/null +++ b/test/e2e/snapshots/autofix-e2e.ts.md @@ -0,0 +1,1211 @@ +# Snapshot report for `test/e2e/autofix-e2e.ts` + +The actual snapshot is saved in `autofix-e2e.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## Compare com.ui5.troublesome.app result snapshots + +> Snapshot 1 + + `ui5.yaml:␊ + specVersion: '3.0'␊ + metadata:␊ + name: com.ui5.troublesome.app␊ + type: application␊ + framework:␊ + name: OpenUI5␊ + version: "1.121.0"␊ + libraries:␊ + - name: sap.m␊ + - name: sap.ui.core␊ + - name: sap.landvisz␊ + ` + +> Snapshot 2 + + `ui5lint-custom-broken.config.cjs:␊ + // CodeQL code scan complains about this file being broken.␊ + // This is a case we test for the config manager. So, wrapping it in a JSON.parse to avoid the error there,␊ + // but keep the test case valid.␊ + module.exports = JSON.parse(\`{␊ + "ignores": [␊ + "test/**/*",␊ + "!test/sap/m/visual/Wizard.spe\`);␊ + ` + +> Snapshot 3 + + `ui5lint-custom.config.cjs:␊ + module.exports = {␊ + ignores: [␊ + "webapp/test/**/*",␊ + "!webapp/test/integration/opaTests.qunit.js",␊ + "ui5.yaml"␊ + ],␊ + };␊ + ` + +> Snapshot 4 + + `ui5lint.config.matched-patterns.mjs:␊ + export default {␊ + files: [␊ + "webapp/controller/*",␊ + "ui5.yaml",␊ + ],␊ + };␊ + ` + +> Snapshot 5 + + `ui5lint.config.mjs:␊ + export default {␊ + files: [␊ + "webapp/**/*"␊ + ],␊ + ignores: [␊ + "test/**/*", ␊ + "!test/sap/m/visual/Wizard.spec.js",␊ + ],␊ + };␊ + ` + +> Snapshot 6 + + `ui5lint.config.unmatched-patterns.mjs:␊ + export default {␊ + files: [␊ + "webapp/**/*",␊ + "unmatched-pattern1",␊ + "unmatched-pattern2",␊ + "unmatched-pattern3",␊ + ],␊ + ignores: [␊ + "test/**/*", ␊ + "!test/sap/m/visual/Wizard.spec.js",␊ + ],␊ + };␊ + ` + +> Snapshot 7 + + `manifest.json:␊ + {␊ + "_version": "1.12.0",␊ + ␊ + "sap.app": {␊ + "id": "com.ui5.troublesome.app",␊ + "type": "application",␊ + "i18n": "i18n/i18n.properties",␊ + "title": "{{appTitle}}",␊ + "description": "{{appDescription}}",␊ + "applicationVersion": {␊ + "version": "1.0.0"␊ + },␊ + "dataSources": {␊ + "v4": {␊ + "uri": "/api/odata-4/",␊ + "type": "OData",␊ + "settings": {␊ + "odataVersion": "4.0"␊ + }␊ + }␊ + }␊ + },␊ + ␊ + "sap.ui": {␊ + "technology": "UI5",␊ + "icons": {},␊ + "deviceTypes": {␊ + "desktop": true,␊ + "tablet": true,␊ + "phone": true␊ + }␊ + },␊ + ␊ + "sap.ui5": {␊ + "rootView": {␊ + "viewName": "com.ui5.troublesome.app.view.App",␊ + "type": "XML",␊ + "async": true,␊ + "id": "app"␊ + },␊ + ␊ + "dependencies": {␊ + "minUI5Version": "1.119.0",␊ + "libs": {␊ + "sap.ui.core": {},␊ + "sap.m": {},␊ + "sap.ui.commons": {}␊ + }␊ + },␊ + ␊ + "handleValidation": true,␊ + ␊ + "contentDensities": {␊ + "compact": true,␊ + "cozy": true␊ + },␊ + ␊ + "resources": {␊ + "js": [{ "uri": "path/to/thirdparty.js" }]␊ + },␊ + ␊ + "models": {␊ + "i18n": {␊ + "type": "sap.ui.model.resource.ResourceModel",␊ + "settings": {␊ + "bundleName": "com.ui5.troublesome.app.i18n.i18n"␊ + }␊ + },␊ + "odata-v4": {␊ + "type": "sap.ui.model.odata.v4.ODataModel",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata-v4-via-dataSource": {␊ + "dataSource": "v4",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata": {␊ + "type": "sap.ui.model.odata.ODataModel",␊ + "settings": {␊ + "serviceUrl": "/api/odata"␊ + }␊ + }␊ + },␊ + ␊ + "routing": {␊ + "config": {␊ + "routerClass": "sap.m.routing.Router",␊ + "viewType": "XML",␊ + "viewPath": "com.ui5.troublesome.app.view",␊ + "controlId": "app",␊ + "controlAggregation": "pages",␊ + "async": true␊ + },␊ + "routes": [␊ + {␊ + "pattern": "",␊ + "name": "main",␊ + "target": "main"␊ + }␊ + ],␊ + "targets": {␊ + "main": {␊ + "viewId": "main",␊ + "viewName": "Main"␊ + }␊ + }␊ + }␊ + }␊ + }␊ + ` + +> Snapshot 8 + + `Component.js:␊ + sap.ui.define(["sap/ui/core/UIComponent", "sap/ui/Device", "./model/models"], function (UIComponent, Device, models) {␊ + "use strict";␊ + ␊ + return UIComponent.extend("com.ui5.troublesome.app.Component", {␊ + metadata: {␊ + manifest: "json"␊ + },␊ + init: function () {␊ + // call the base component's init function␊ + UIComponent.prototype.init.call(this); // create the views based on the url/hash␊ + ␊ + // create the device model␊ + this.setModel(models.createDeviceModel(), "device");␊ + ␊ + // create the views based on the url/hash␊ + this.getRouter().initialize();␊ + },␊ + /**␊ + * This method can be called to determine whether the sapUiSizeCompact or sapUiSizeCozy␊ + * design mode class should be set, which influences the size appearance of some controls.␊ + * @public␊ + * @returns {string} css class, either 'sapUiSizeCompact' or 'sapUiSizeCozy' - or an empty string if no css class should be set␊ + */␊ + getContentDensityClass: function () {␊ + if (this.contentDensityClass === undefined) {␊ + // check whether FLP has already set the content density class; do nothing in this case␊ + if (document.body.classList.contains("sapUiSizeCozy") || document.body.classList.contains("sapUiSizeCompact")) {␊ + this.contentDensityClass = "";␊ + } else if (!Device.support.touch) {␊ + // apply "compact" mode if touch is not supported␊ + this.contentDensityClass = "sapUiSizeCompact";␊ + } else {␊ + // "cozy" in case of touch support; default for most sap.m controls, but needed for desktop-first controls like sap.ui.table.Table␊ + this.contentDensityClass = "sapUiSizeCozy";␊ + }␊ + }␊ + return this.contentDensityClass;␊ + }␊ + });␊ + });␊ + ` + +> Snapshot 9 + + `emptyFile.js:␊ + ` + +> Snapshot 10 + + `index-cdn.html:␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + UI5 Application: com.ui5.troublesome.app␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
␊ + ␊ + ␊ + ` + +> Snapshot 11 + + `index.html:␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + UI5 Application: com.ui5.troublesome.app␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
␊ + ␊ + ␊ + ` + +> Snapshot 12 + + `manifest.json:␊ + {␊ + "_version": "1.12.0",␊ + ␊ + "sap.app": {␊ + "id": "com.ui5.troublesome.app",␊ + "type": "application",␊ + "i18n": "i18n/i18n.properties",␊ + "title": "{{appTitle}}",␊ + "description": "{{appDescription}}",␊ + "applicationVersion": {␊ + "version": "1.0.0"␊ + },␊ + "dataSources": {␊ + "v4": {␊ + "uri": "/api/odata-4/",␊ + "type": "OData",␊ + "settings": {␊ + "odataVersion": "4.0"␊ + }␊ + }␊ + }␊ + },␊ + ␊ + "sap.ui": {␊ + "technology": "UI5",␊ + "icons": {},␊ + "deviceTypes": {␊ + "desktop": true,␊ + "tablet": true,␊ + "phone": true␊ + }␊ + },␊ + ␊ + "sap.ui5": {␊ + "rootView": {␊ + "viewName": "com.ui5.troublesome.app.view.App",␊ + "type": "XML",␊ + "async": true,␊ + "id": "app"␊ + },␊ + ␊ + "dependencies": {␊ + "minUI5Version": "1.119.0",␊ + "libs": {␊ + "sap.ui.core": {},␊ + "sap.m": {},␊ + "sap.ui.commons": {}␊ + }␊ + },␊ + ␊ + "handleValidation": true,␊ + ␊ + "contentDensities": {␊ + "compact": true,␊ + "cozy": true␊ + },␊ + ␊ + "resources": {␊ + "js": [{ "uri": "path/to/thirdparty.js" }]␊ + },␊ + ␊ + "models": {␊ + "i18n": {␊ + "type": "sap.ui.model.resource.ResourceModel",␊ + "settings": {␊ + "bundleName": "com.ui5.troublesome.app.i18n.i18n"␊ + }␊ + },␊ + "odata-v4": {␊ + "type": "sap.ui.model.odata.v4.ODataModel",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata-v4-via-dataSource": {␊ + "dataSource": "v4",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata": {␊ + "type": "sap.ui.model.odata.ODataModel",␊ + "settings": {␊ + "serviceUrl": "/api/odata"␊ + }␊ + }␊ + },␊ + ␊ + "routing": {␊ + "config": {␊ + "routerClass": "sap.m.routing.Router",␊ + "viewType": "XML",␊ + "viewPath": "com.ui5.troublesome.app.view",␊ + "controlId": "app",␊ + "controlAggregation": "pages",␊ + "async": true␊ + },␊ + "routes": [␊ + {␊ + "pattern": "",␊ + "name": "main",␊ + "target": "main"␊ + }␊ + ],␊ + "targets": {␊ + "main": {␊ + "viewId": "main",␊ + "viewName": "Main"␊ + }␊ + }␊ + }␊ + }␊ + }␊ + ` + +> Snapshot 13 + + `App.view.xml:␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Snapshot 14 + + `DesktopMain.view.xml:␊ + ␊ + ␊ + ␊ +