From 074730c4661cc2f262d04771545048dacf5be7c4 Mon Sep 17 00:00:00 2001 From: svc-cli-bot Date: Tue, 29 Jul 2025 09:17:01 -0500 Subject: [PATCH 01/17] Updating SHA256.md after 1.9.0 release --- SHA256.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SHA256.md b/SHA256.md index c2753a14..b59161f0 100644 --- a/SHA256.md +++ b/SHA256.md @@ -15,7 +15,7 @@ make sure that their SHA values match the values in the list below. shasum -a 256 3. Confirm that the SHA in your output matches the value in this list of SHAs. - 112139aa0cd29e64729aceaea9fc1c19c072b95af7eda751ca9ad92a346a9783 ./extensions/sfdx-code-analyzer-vscode-1.8.0.vsix + a26fa56962cc53dfd3deeb0a32f184218e71b0110eb96b1a56966d363538fee6 ./extensions/sfdx-code-analyzer-vscode-1.9.0.vsix 4. Change the filename extension for the file that you downloaded from .zip to .vsix. From 0b421ad4ab4732d4a7d9f97141e7898dda13cd19 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:39:06 -0400 Subject: [PATCH 02/17] =?UTF-8?q?CHANGE:=20@W-18538299@:=20Refactor=20fix?= =?UTF-8?q?=20generator=20stuff=20to=20allow=20apex=20guru=E2=80=A6=20(#26?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 62 +- src/extension.ts | 20 +- src/lib/fixer.ts | 339 ----------- src/lib/fixes-code-action-provider.ts | 56 ++ src/lib/messages.ts | 2 +- .../pmd-suppressions-code-action-provider.ts | 201 +++++++ src/test/code-fixtures/fixer-tests/MyDoc1.xml | 20 - src/test/legacy/fixer.test.ts | 539 ------------------ .../lib/fixes-code-action-provider.test.ts | 152 +++++ ...-suppressions-code-action-provider.test.ts | 196 +++++++ src/test/unit/test-utils.ts | 4 +- tsconfig.json | 1 + 12 files changed, 625 insertions(+), 967 deletions(-) delete mode 100644 src/lib/fixer.ts create mode 100644 src/lib/fixes-code-action-provider.ts create mode 100644 src/lib/pmd/pmd-suppressions-code-action-provider.ts delete mode 100644 src/test/code-fixtures/fixer-tests/MyDoc1.xml delete mode 100644 src/test/legacy/fixer.test.ts create mode 100644 src/test/unit/lib/fixes-code-action-provider.test.ts create mode 100644 src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts diff --git a/package-lock.json b/package-lock.json index 915e4b56..ad8444fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sfdx-code-analyzer-vscode", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sfdx-code-analyzer-vscode", - "version": "1.8.0", + "version": "1.9.0", "license": "BSD-3-Clause", "dependencies": { "@salesforce/vscode-service-provider": "^1.4.0", @@ -814,40 +814,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/core": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", - "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz", - "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.6", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", @@ -1754,19 +1720,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, "node_modules/@node-rs/crc32": { "version": "1.10.6", "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.6.tgz", @@ -2239,17 +2192,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/src/extension.ts b/src/extension.ts index adb37765..1d057349 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,7 +11,6 @@ import {SettingsManager, SettingsManagerImpl} from './lib/settings'; import * as targeting from './lib/targeting' import {CodeAnalyzerDiagnostic, DiagnosticManager, DiagnosticManagerImpl} from './lib/diagnostics'; import {messages} from './lib/messages'; -import {Fixer} from './lib/fixer'; import {CoreExtensionService} from './lib/core-extension-service'; import * as Constants from './lib/constants'; import * as path from 'path'; @@ -34,6 +33,8 @@ import {getErrorMessage} from "./lib/utils"; import {FileHandler, FileHandlerImpl} from "./lib/fs-utils"; import {VscodeWorkspace, VscodeWorkspaceImpl, WindowManager, WindowManagerImpl} from "./lib/vscode-api"; import {Workspace} from "./lib/workspace"; +import { PMDSupressionsCodeActionProvider } from './lib/pmd/pmd-suppressions-code-action-provider'; +import { FixesCodeActionProvider } from './lib/fixes-code-action-provider'; // Object to hold the state of our extension for a specific activation context, to be returned by our activate function @@ -200,18 +201,18 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.Uri.file(f))); }); - // ================================================================================================================= - // == Code Analyzer Basic Quick-Fix Functionality + // == Code Analyzer PMD Quick-Fix Functionality for Line or Class Level Suppressions // ================================================================================================================= - registerCodeActionsProvider({pattern: '**/**'}, new Fixer(), // TODO: We should separate the apex guru quick fix from this Fixer class into its own - {providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]}); + const pmdSuppressionsCodeActionProvider: PMDSupressionsCodeActionProvider = new PMDSupressionsCodeActionProvider(); + registerCodeActionsProvider({language: 'apex'}, pmdSuppressionsCodeActionProvider, + {providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]}); // QF_COMMAND_DIAGNOSTICS_IN_RANGE: Invoked by a Quick Fix button that appears on diagnostics + // TODO: We need to fix this - because we should be just removing the relevant diagnostics - not all in a specific range registerCommand(Constants.QF_COMMAND_DIAGNOSTICS_IN_RANGE, (uri: vscode.Uri, range: vscode.Range) => diagnosticManager.clearDiagnosticsInRange(uri, range)); - // ================================================================================================================= // == DFA Run Functionality // ================================================================================================================= @@ -277,6 +278,13 @@ export async function activate(context: vscode.ExtensionContext): Promise(); - // Filter out diagnostics that aren't ours, or are for the wrong line. - return context.diagnostics.filter(d => d instanceof CodeAnalyzerDiagnostic) - .filter(d => !d.isStale()) - // Technically, I don't think VS Code sends in diagnostics that aren't overlapping with the users selection, - // but just in case they do, then this last filter is an additional sanity check just to be safe - .filter(d => range.intersection(d.range) != undefined) - // Get and use the appropriate fix generator. - .map(diagnostic => this.getFixGenerator(document, diagnostic).generateFixes(processedLines, document, diagnostic)) - // Combine all the fixes into one array. - .reduce((acc, next) => [...acc, ...next], []); - } - - /** - * Gets a {@link FixGenerator} corresponding to the engine that created the given diagnostic, - * or a {@link _NoOpFixGenerator} if no engine-specific generator is available. - * @param document - * @param diagnostic - * @returns - */ - private getFixGenerator(document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic): FixGenerator { - switch (diagnostic.violation.engine) { - case 'pmd': - case 'pmd-custom': - return new _PmdFixGenerator(document, diagnostic); - case 'apexguru': - return new _ApexGuruFixGenerator(document, diagnostic); - default: - return new _NoOpFixGenerator(document, diagnostic); - } - } -} - -/** - * Abstract parent class for engine-specific fix generators. - * @abstract - */ -abstract class FixGenerator { - protected document: vscode.TextDocument; - protected diagnostic: CodeAnalyzerDiagnostic; - - /** - * - * @param document A document to which fixes should be added - * @param diagnostic The diagnostic from which fixes should be generated - */ - public constructor(document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic) { - this.document = document; - this.diagnostic = diagnostic; - } - - /** - * Abstract template method for generating fixes. - * @abstract - */ - public abstract generateFixes(processedLines: Set, document?: vscode.TextDocument, diagnostic?: CodeAnalyzerDiagnostic): vscode.CodeAction[]; -} - -/** - * FixGenerator to be used by default when no FixGenerator exists for a given engine. Does nothing. - * @private Must be exported for testing purposes, but shouldn't be used publicly, hence the leading underscore. - */ -export class _NoOpFixGenerator extends FixGenerator { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public generateFixes(processedLines: Set): vscode.CodeAction[] { - return []; - } -} - -export class _ApexGuruFixGenerator extends FixGenerator { - /** - * Generate an array of fixes, if possible. - * @returns - */ - public generateFixes(processedLines: Set, document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic): vscode.CodeAction[] { - console.log(diagnostic); - const fixes: vscode.CodeAction[] = []; - const lineNumber = this.diagnostic.range.start.line; - if (!processedLines.has(lineNumber)) { - fixes.push(this.generateApexGuruSuppresssion(document)) - processedLines.add(lineNumber); - } - return fixes; - } - - public generateApexGuruSuppresssion(document: vscode.TextDocument): vscode.CodeAction { - const suggestedCode = this.diagnostic.relatedInformation[1].message; - - const action = new vscode.CodeAction(messages.fixer.fixWithApexGuruSuggestions, vscode.CodeActionKind.QuickFix); - action.diagnostics = [this.diagnostic]; - const range = this.diagnostic.range; // Assuming the range is the location of the existing code in the document - const diagnosticStartLine = new vscode.Position(range.start.line, range.start.character); - - action.command = { - title: 'Apply ApexGuru Fix', - command: Constants.QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS, - arguments: [document, diagnosticStartLine, suggestedCode + '\n'] - } - - return action; - } - -} - -/** - * FixGenerator to be used for PMD and Custom PMD. - * @private Must be exported for testing purposes, but shouldn't be used publicly, hence the leading underscore. - */ -export class _PmdFixGenerator extends FixGenerator { - public singleLineCommentPattern = /^\s*\/\//; - public blockCommentStartPattern = /^\s*\/\*/; - public blockCommentEndPattern = /\*\//; - public classDeclarationPattern = /\b(\w+\s+)+class\s+\w+/; - public suppressionRegex = /@SuppressWarnings\s*\(\s*["']([^"']*)["']\s*\)/i; - - /** - * Generate an array of fixes, if possible. - * @returns - */ - public generateFixes(processedLines: Set): vscode.CodeAction[] { - const fixes: vscode.CodeAction[] = []; - if (this.documentSupportsLineLevelSuppression()) { - // We only check for the start line and not the entire range because irrespective of the range of a specific violation, - // we add the NOPMD tag only on the first line of the violation. - const lineNumber = this.diagnostic.range.start.line; - if (!processedLines.has(lineNumber)) { - fixes.push(this.generateLineLevelSuppression()); - processedLines.add(lineNumber); - } - fixes.push(this.generateClassLevelSuppression()); - } - return fixes; - } - - /** - * Not all languages support line-level PMD violation suppression. This method - * verifies that the target document does. - * @returns - */ - private documentSupportsLineLevelSuppression(): boolean { - const lang = this.document.languageId; - // Of the languages we support, Apex and Java are the ones - // that support line-level suppression. - return lang === 'apex' || lang === 'java'; - } - - /** - * - * @returns An action that will apply a line-level suppression to the targeted diagnostic. - */ - private generateLineLevelSuppression(): vscode.CodeAction { - // Create a position indicating the very end of the violation's start line. - const endOfLine: vscode.Position = new vscode.Position(this.diagnostic.range.start.line, Number.MAX_SAFE_INTEGER); - - const action = new vscode.CodeAction(messages.fixer.suppressPMDViolationsOnLine, vscode.CodeActionKind.QuickFix); - action.edit = new vscode.WorkspaceEdit(); - action.edit.insert(this.document.uri, endOfLine, " // NOPMD"); - action.diagnostics = [this.diagnostic]; - action.command = { - command: Constants.QF_COMMAND_DIAGNOSTICS_IN_RANGE, // TODO: This is wrong. We should only be clearing PMD violations on this line - not all within the range - title: 'Clear Single Diagnostic', - arguments: [this.document.uri, this.diagnostic.range] - }; - - return action; - } - - public generateClassLevelSuppression(): vscode.CodeAction { - // Find the end-of-line position of the class declaration where the diagnostic is found. - const classStartPosition = this.findClassStartPosition(this.diagnostic, this.document); - - const ruleName: string = this.diagnostic.violation.rule; - const suppressionTag: string = ruleName ? `PMD.${ruleName}` : - `PMD`; // TODO: Figure out when this would ever be the case?? I don't think we should blindly suppress everything - const suppressMsg: string = messages.fixer.suppressPmdViolationsOnClass(ruleName); - - const action = new vscode.CodeAction(suppressMsg, vscode.CodeActionKind.QuickFix); - action.edit = new vscode.WorkspaceEdit(); - - // Extract text from the start to end of the class declaration to search for existing suppressions - const classText = this.findLineBeforeClassStartDeclaration(classStartPosition, this.document); - const suppressionMatch = classText.match(this.suppressionRegex); - - if (suppressionMatch) { - // If @SuppressWarnings exists, check if the rule is already present - const existingRules = suppressionMatch[1].split(',').map(rule => rule.trim()); - if (!existingRules.includes(suppressionTag)) { - // If the rule is not present, add it to the existing @SuppressWarnings - const updatedRules = [...existingRules, suppressionTag].join(', '); - const updatedSuppression = this.generateUpdatedSuppressionTag(updatedRules, this.document.languageId); - const suppressionStartPosition = this.document.positionAt(classText.indexOf(suppressionMatch[0])); - const suppressionEndPosition = this.document.positionAt(classText.indexOf(suppressionMatch[0]) + suppressionMatch[0].length); - const suppressionRange = new vscode.Range(suppressionStartPosition, suppressionEndPosition); - action.edit.replace(this.document.uri, suppressionRange, updatedSuppression); - } - } else { - // If @SuppressWarnings does not exist, insert a new one - const newSuppression = this.generateNewSuppressionTag(suppressionTag, this.document.languageId); - action.edit.insert(this.document.uri, classStartPosition, newSuppression); - } - - action.diagnostics = [this.diagnostic]; - action.command = { - command: Constants.COMMAND_REMOVE_DIAGNOSTICS_ON_SELECTED_FILE, - title: 'Remove diagnostics for this file', - arguments: [this.document.uri] - }; - - return action; - } - - public generateUpdatedSuppressionTag(updatedRules: string, lang: string) { - if (lang === 'apex') { - return `@SuppressWarnings('${updatedRules}')`; - } else if (lang === 'java') { - return `@SuppressWarnings("${updatedRules}")`; - } - return ''; - } - - public generateNewSuppressionTag(suppressionRule: string, lang: string) { - if (lang === 'apex') { - return `@SuppressWarnings('${suppressionRule}')\n`; - } else if (lang === 'java') { - return `@SuppressWarnings("${suppressionRule}")\n`; - } - return ''; - } - - /** - * Finds the start position of the class in the document. - * Assumes that the class declaration starts with the keyword "class". - * @returns The position at the start of the class. - */ - public findClassStartPosition(diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument): vscode.Position { - const text = document.getText(); - const diagnosticLine = diagnostic.range.start.line; - - // Split the text into lines for easier processing - const lines = text.split('\n'); - let classStartLine: number | undefined; - - let inBlockComment = false; - - // Iterate from the diagnostic line upwards to find the class declaration - for (let lineNumber = 0; lineNumber <= diagnosticLine; lineNumber++) { - const line = lines[lineNumber]; - - // Check if this line is the start of a block comment - if (!inBlockComment && line.match(this.blockCommentStartPattern)) { - inBlockComment = true; - continue; - } - - // Check if we are in the end of block comment - if (inBlockComment && line.match(this.blockCommentEndPattern)) { - inBlockComment = false; - continue; - } - - // Skip single-line comments - if (line.match(this.singleLineCommentPattern)) { - continue; - } - - // Skip block comment in a single line - if (line.match(this.blockCommentEndPattern) && line.match(this.blockCommentStartPattern)) { - continue; - } - - const match = line.match(this.classDeclarationPattern); - if (!inBlockComment && match && !this.isWithinQuotes(line, match.index)) { - classStartLine = lineNumber; - break; - } - } - - if (classStartLine !== undefined) { - return new vscode.Position(classStartLine, 0); - } - - // Default to the start of the document if class is not found - return new vscode.Position(0, 0); - } - - /** - * Finds the entire line that is one line above a class declaration statement. - * @returns The text of the line that is one line above the class declaration. - */ - public findLineBeforeClassStartDeclaration(classStartPosition: vscode.Position, document: vscode.TextDocument): string { - // Ensure that there is a line before the class declaration - if (classStartPosition.line > 0) { - const lineBeforeClassPosition = classStartPosition.line - 1; - const lineBeforeClass = document.lineAt(lineBeforeClassPosition); - return lineBeforeClass.text; - } - - // Return an empty string if it's the first line of the document - return ''; - } - - /** - * Helper function to check if match is within quotes - * @param line - * @param matchIndex - * @returns - */ - public isWithinQuotes(line: string, matchIndex: number): boolean { - const beforeMatch = line.slice(0, matchIndex); - const singleQuotesBefore = (beforeMatch.match(/'/g) || []).length; - const doubleQuotesBefore = (beforeMatch.match(/"/g) || []).length; - - // Check if the number of quotes before the match is odd (inside quotes) - return singleQuotesBefore % 2 !== 0 || doubleQuotesBefore % 2 !== 0 - } -} diff --git a/src/lib/fixes-code-action-provider.ts b/src/lib/fixes-code-action-provider.ts new file mode 100644 index 00000000..0e5e76ce --- /dev/null +++ b/src/lib/fixes-code-action-provider.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import * as vscode from 'vscode'; +import {messages} from './messages'; +import * as Constants from './constants'; +import {CodeAnalyzerDiagnostic} from "./diagnostics"; + +/** + * Class for providing quick fix functionality to diagnostics associated with Code Analyzer violations that contain fixes + * + * NOTE: Currently this is hard coded to only work on ApexGuru based violations - but soon will be generalized to work on all violations + */ +export class FixesCodeActionProvider implements vscode.CodeActionProvider { + public provideCodeActions(document: vscode.TextDocument, selectedRange: vscode.Range, context: vscode.CodeActionContext): vscode.CodeAction[] { + const filteredDiagnostics: CodeAnalyzerDiagnostic[] = context.diagnostics + .filter(d => d instanceof CodeAnalyzerDiagnostic) + .filter(d => !d.isStale()) + + // THIS IS TEMPORARY - WE'LL SWITCH THIS FILTER TO INSPECT THE VIOLATION SOON INSTEAD OF LOOKING FOR apexguru diagnostics that have relatedInformation + .filter(d => d.violation.engine === 'apexguru') + .filter(d => d.relatedInformation && d.relatedInformation.length > 0) + + // Technically, I don't think VS Code sends in diagnostics that aren't overlapping with the users selection, + // but just in case they do, then this last filter is an additional sanity check just to be safe + .filter(d => selectedRange.intersection(d.range) != undefined) + if (filteredDiagnostics.length == 0) { + return []; + } + + return filteredDiagnostics.map(diag => createCodeAction(document, diag)).flat(); + } +} + +function createCodeAction(document: vscode.TextDocument, diag: CodeAnalyzerDiagnostic): vscode.CodeAction { + const suggestedCode = diag.relatedInformation[1].message; // <-- !! THIS IS A BAD ASSUMPTION - WILL FIX THIS SOON !! + + const action = new vscode.CodeAction( + messages.fixer.fixWithApexGuruSuggestions, // TODO: This will go away soon in favor of a generalized message + vscode.CodeActionKind.QuickFix); + action.diagnostics = [diag]; + const range = diag.range; // Assuming the range is the location of the existing code in the document // <--- !! THIS IS A BAD ASSUMPTION - WILL FIX SOON !! + const diagnosticStartLine = new vscode.Position(range.start.line, range.start.character); + + // TODO: WILL REWORK THIS SOON IN FAVOR OF A MORE GENERALIZED APPROACH + action.command = { + title: 'Apply ApexGuru Fix', + command: Constants.QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS, + arguments: [document, diagnosticStartLine, suggestedCode + '\n'] + } + + return action; +} \ No newline at end of file diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 1991b63d..7c26569a 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -48,7 +48,7 @@ export const messages = { fixer: { suppressPMDViolationsOnLine: "Suppress all PMD violations on this line.", suppressPmdViolationsOnClass: (ruleName?: string) => ruleName ? `Suppress '${ruleName}' on this class.` : `Suppress all PMD violations on this class.`, - fixWithApexGuruSuggestions: "Insert ApexGuru suggestions." + fixWithApexGuruSuggestions: "Insert ApexGuru suggestions." // TODO: This will go away soon in favor of a generalized message }, diagnostics: { messageGenerator: (severity: number, message: string) => `Sev${severity}: ${message}`, diff --git a/src/lib/pmd/pmd-suppressions-code-action-provider.ts b/src/lib/pmd/pmd-suppressions-code-action-provider.ts new file mode 100644 index 00000000..99861b71 --- /dev/null +++ b/src/lib/pmd/pmd-suppressions-code-action-provider.ts @@ -0,0 +1,201 @@ +import * as vscode from "vscode"; +import { CodeAnalyzerDiagnostic } from "../diagnostics"; +import { messages } from "../messages"; +import * as Constants from '../constants'; + +export class PMDSupressionsCodeActionProvider implements vscode.CodeActionProvider { + provideCodeActions(document: vscode.TextDocument, selectedRange: vscode.Range | vscode.Selection, context: vscode.CodeActionContext): vscode.CodeAction[] { + if (document.languageId !== 'apex') { + return []; + } + + const filteredDiagnostics: CodeAnalyzerDiagnostic[] = context.diagnostics + .filter(d => d instanceof CodeAnalyzerDiagnostic) + .filter(d => !d.isStale()) + .filter(d => d.violation.engine === 'pmd') + // Technically, I don't think VS Code sends in diagnostics that aren't overlapping with the users selection, + // but just in case they do, then this last filter is an additional sanity check just to be safe + .filter(d => selectedRange.intersection(d.range) != undefined) + if (filteredDiagnostics.length == 0) { + return []; + } + + // To avoid creating multiple "quick fix" suggestions for suppressing PMD violations on a single line, we keep + // track of the line numbers where we provide the line level suppression suggestion for. Note it is assumed that + // a PMD diagnostic can't exist on a line that already has a "// NOPMD" suppression marker. + const suppressedLines = new Set(); + return filteredDiagnostics.map(diag => generateFixes(suppressedLines, document, diag)).flat(); + } +} + +const PATTERNS: Record = { + singleLineComment: /^\s*\/\//, + blockCommentStart: /^\s*\/\*/, + blockCommentEnd: /\*\//, + classDeclaration: /\b(\w+\s+)+class\s+\w+/, + suppressionAnnotation: /@SuppressWarnings\s*\(\s*["']([^"']*)["']\s*\)/i +} + +function generateFixes(suppressedLines: Set, document: vscode.TextDocument, diag: CodeAnalyzerDiagnostic): vscode.CodeAction[] { + const fixes: vscode.CodeAction[] = []; + // We only check for the start line and not the entire range because irrespective of the range of a specific violation, + // we add the NOPMD tag only on the first line of the violation. + const lineNumber = diag.range.start.line; + if (!suppressedLines.has(lineNumber)) { + fixes.push(generateLineLevelSuppression(document, diag)); + suppressedLines.add(lineNumber); + } + fixes.push(generateClassLevelSuppression(document, diag)); + return fixes; +} + +/** + * + * @returns An action that will apply a line-level suppression to the targeted diagnostic. + */ +function generateLineLevelSuppression(document: vscode.TextDocument, diag: CodeAnalyzerDiagnostic): vscode.CodeAction { + // Create a position indicating the very end of the violation's start line. + const endOfStartLine: vscode.Position = new vscode.Position(diag.range.start.line, Number.MAX_SAFE_INTEGER); + + const action = new vscode.CodeAction(messages.fixer.suppressPMDViolationsOnLine, vscode.CodeActionKind.QuickFix); + action.edit = new vscode.WorkspaceEdit(); + action.edit.insert(document.uri, endOfStartLine, " // NOPMD"); + action.diagnostics = [diag]; + action.command = { + command: Constants.QF_COMMAND_DIAGNOSTICS_IN_RANGE, // TODO: This is wrong. We should only be clearing the PMD violations on this line - not all within the range + title: 'Clear Single Diagnostic', + arguments: [document.uri, diag.range] + }; + + return action; +} + +function generateClassLevelSuppression(document: vscode.TextDocument, diag: CodeAnalyzerDiagnostic): vscode.CodeAction { + // Find the end-of-line position of the class declaration where the diagnostic is found. + const classStartPosition = findClassStartPosition(document, diag); + + const ruleName: string = diag.violation.rule; + const suppressionTag: string = `PMD.${ruleName}`; + const suppressMsg: string = messages.fixer.suppressPmdViolationsOnClass(suppressionTag); + + const action = new vscode.CodeAction(suppressMsg, vscode.CodeActionKind.QuickFix); + action.edit = new vscode.WorkspaceEdit(); + + // Extract text from the start to end of the class declaration to search for existing suppressions + const classText = findLineBeforeClassStartDeclaration(classStartPosition, document); + const suppressionMatch = classText.match(PATTERNS.suppressionAnnotation); + + if (suppressionMatch) { + // If @SuppressWarnings exists, check if the rule is already present + const existingSuppressionTags = suppressionMatch[1].split(',').map(rule => rule.trim()); + if (!existingSuppressionTags.includes(suppressionTag)) { + // If the rule is not present, add it to the existing @SuppressWarnings + const updatedSuppressionTagsList = [...existingSuppressionTags, suppressionTag].join(', '); + const updatedSuppression = `@SuppressWarnings('${updatedSuppressionTagsList}')`; + const suppressionStartPosition = document.positionAt(classText.indexOf(suppressionMatch[0])); + const suppressionEndPosition = document.positionAt(classText.indexOf(suppressionMatch[0]) + suppressionMatch[0].length); + const suppressionRange = new vscode.Range(suppressionStartPosition, suppressionEndPosition); + action.edit.replace(document.uri, suppressionRange, updatedSuppression); + } + } else { + // If @SuppressWarnings does not exist, insert a new one + const newSuppression = `@SuppressWarnings('${suppressionTag}')\n`; + action.edit.insert(document.uri, classStartPosition, newSuppression); + } + + action.diagnostics = [diag]; + action.command = { + command: Constants.COMMAND_REMOVE_DIAGNOSTICS_ON_SELECTED_FILE, // TODO: This is wrong. It should only clear the PMD diagnostics within the class instead of all diagnostics within the file + title: 'Remove diagnostics for this file', + arguments: [document.uri] + }; + + return action; +} + +/** + * Finds the start position of the class in the document. + * Assumes that the class declaration starts with the keyword "class". + * @returns The position at the start of the class. + */ +function findClassStartPosition(document: vscode.TextDocument, diag: CodeAnalyzerDiagnostic): vscode.Position { + const text = document.getText(); + const diagnosticLine = diag.range.start.line; + + // Split the text into lines for easier processing + const lines = text.split('\n'); + let classStartLine: number | undefined; + + let inBlockComment = false; + + // Iterate from the diagnostic line upwards to find the class declaration + for (let lineNumber = 0; lineNumber <= diagnosticLine; lineNumber++) { + const line = lines[lineNumber]; + + // Check if this line is the start of a block comment + if (!inBlockComment && line.match(PATTERNS.blockCommentStart)) { + inBlockComment = true; + continue; + } + + // Check if we are in the end of block comment + if (inBlockComment && line.match(PATTERNS.blockCommentEnd)) { + inBlockComment = false; + continue; + } + + // Skip single-line comments + if (line.match(PATTERNS.singleLineComment)) { + continue; + } + + // Skip block comment in a single line + if (line.match(PATTERNS.blockCommentEnd) && line.match(PATTERNS.blockCommentStart)) { + continue; + } + + const match = line.match(PATTERNS.classDeclaration); + if (!inBlockComment && match && !isWithinQuotes(line, match.index)) { + classStartLine = lineNumber; + break; + } + } + + if (classStartLine !== undefined) { + return new vscode.Position(classStartLine, 0); + } + + // Default to the start of the document if class is not found + return new vscode.Position(0, 0); +} + +/** + * Finds the entire line that is one line above a class declaration statement. + * @returns The text of the line that is one line above the class declaration. + */ +function findLineBeforeClassStartDeclaration(classStartPosition: vscode.Position, document: vscode.TextDocument): string { + // Ensure that there is a line before the class declaration + if (classStartPosition.line > 0) { + const lineBeforeClassPosition = classStartPosition.line - 1; + const lineBeforeClass = document.lineAt(lineBeforeClassPosition); + return lineBeforeClass.text; + } + + // Return an empty string if it's the first line of the document + return ''; +} + +/** + * Helper function to check if match is within quotes + * @param line + * @param matchIndex + * @returns + */ +function isWithinQuotes(line: string, matchIndex: number): boolean { + const beforeMatch = line.slice(0, matchIndex); + const singleQuotesBefore = (beforeMatch.match(/'/g) || []).length; + const doubleQuotesBefore = (beforeMatch.match(/"/g) || []).length; + + // Check if the number of quotes before the match is odd (inside quotes) + return singleQuotesBefore % 2 !== 0 || doubleQuotesBefore % 2 !== 0 +} \ No newline at end of file diff --git a/src/test/code-fixtures/fixer-tests/MyDoc1.xml b/src/test/code-fixtures/fixer-tests/MyDoc1.xml deleted file mode 100644 index 719dab2c..00000000 --- a/src/test/code-fixtures/fixer-tests/MyDoc1.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - this cdata section is valid, but it contains an // NOPMD - additional square bracket at the beginning. - It should probably be just . - - - - this cdata section is valid, but it contains an - additional square bracket in the end. - It should probably be just . - - - diff --git a/src/test/legacy/fixer.test.ts b/src/test/legacy/fixer.test.ts deleted file mode 100644 index 2de3652a..00000000 --- a/src/test/legacy/fixer.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as vscode from 'vscode'; -import {expect} from 'chai'; -import * as path from 'path'; -import * as Constants from '../../lib/constants'; -import {_NoOpFixGenerator, _PmdFixGenerator, _ApexGuruFixGenerator} from '../../lib/fixer'; -import {CodeAnalyzerDiagnostic} from "../../lib/diagnostics"; -import { createSampleCodeAnalyzerDiagnostic } from '../unit/test-utils'; - -suite('fixer.ts', () => { - // Note: Because this is a mocha test, __dirname here is actually the location of the js file in the out/test folder. - const codeFixturesPath: string = path.resolve(__dirname, '..', '..', '..', 'src', 'test', 'code-fixtures'); - - teardown(async () => { - // Close any open tabs and close the active editor. - await vscode.commands.executeCommand('workbench.action.closeAllEditors'); - }); - - suite('_NoOpFixGenerator', () => { - const existingFixes = new Set(); - suite('#generateFixes()', () => { - test('Returns empty array', () => { - // Doesn't matter what we feed to the no-op constructor. - const fixGenerator = new _NoOpFixGenerator(null, null); - - // Attempt to generate fixes. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(existingFixes); - - // Verify array is empty. - expect(fixes).to.have.lengthOf(0, 'Should be no fixes'); - }); - }); - }); - - suite('_PmdFixGenerator', () => { - suite('#generateFixes()', () => { - suite('XML doc', () => { - // Get the URI for the XML doc. - const xmlDocUri: vscode.Uri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyDoc1.xml')); - - // At this time, we don't support injecting suppression for XML. - test('No fixes are offered', async () => { - // Open the document. - const doc = await vscode.workspace.openTextDocument(xmlDocUri); - await vscode.window.showTextDocument(doc); - // Create a fake diagnostic. - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - xmlDocUri, new vscode.Range(7, 1, 7, 15)); - - // Instantiate our fixer. - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Attempt to generate fixes for the file. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(null); - - // We should get none. - expect(fixes).to.have.lengthOf(0, 'No fixes should be offered'); - }); - }); - - suite('Apex doc', () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass1.cls')); - - let doc: vscode.TextDocument; - // Load the document and store its starting contents. - setup(async () => { - doc = await vscode.workspace.openTextDocument(fileUri); - await vscode.window.showTextDocument(doc); - }); - - suite('Line-level suppression', () => { - const existingFixes = new Set(); - test('Appends suppression to end of commentless line', () => { - // Create our fake diagnostic, positioned at the line with no comment at the end. - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(7, 4, 7, 10)); - - // Instantiate our fixer. - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Attempt to generate fixes for the file. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(existingFixes); - - // We expect to get one fix, to inject the suppression at the end of the line. - expect(fixes).to.have.lengthOf(2, 'Wrong action count'); - const fix = fixes[0].edit.get(fileUri)[0]; - expect(fix.newText).to.equal(' // NOPMD', 'Wrong suppression added'); - expect(fix.range.start.isEqual(new vscode.Position(7, Number.MAX_SAFE_INTEGER))).to.equal(true, 'Should be at the end of the violation line'); - }); - - test('Does not add suppression if suppression for that same line already exists', () => { - existingFixes.add(7); - // Create our fake diagnostic whose start position is the same as the existing fix already added - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(7, 0, 8, 0)); - - // Instantiate our fixer. - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Attempt to generate fixes for the file. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(existingFixes); - - // We expect to get one fix (class level suppression), no new line level suppression added since there is already a fix - expect(fixes).to.have.lengthOf(1, 'Wrong action count'); - }); - - /* - THIS TEST IS DISABLED FOR NOW. WHEN WE FIX BUG W-13816110, ENABLE THIS TEST. - test('Injects suppression at start of existing end-of-line comment', () => { - // Create our fake diagnostic, positioned at the line with a comment at the end. - const diag = new vscode.Diagnostic( - new vscode.Range( - new vscode.Position(12, 4), - new vscode.Position(12, 10) - ), - 'This message is unimportant', - vscode.DiagnosticSeverity.Warning - ); - - // Instantiate our fixer. - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Attempt to generate fixes for the file. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(); - - // We expect to get one fix, to inject the suppression at the s tart of the comment that ends the line. - expect(fixes).to.have.lengthOf(1, 'Wrong action count'); - const fix = fixes[0].edit.get(fileUri)[0]; - expect(fix.newText).to.equal(' NOPMD', 'Wrong suppression added'); - expect(fix.range.start.isEqual(new vscode.Position(12, 53))).to.equal(true, 'Should be at start of end-of-line comment'); - }); - */ - }); - - suite('Class-level suppression', () => { - suite('#findClassStartPosition()', () => { - test('Should find class start position above the diagnostic line', async () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass1.cls')); - - await vscode.workspace.openTextDocument(fileUri); - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(7, 4, 7, 10)); - - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Call findClassStartPosition method - const position = fixGenerator.findClassStartPosition(diag, doc); - - // Verify the position is correct - expect(position.line).to.equal(6); - expect(position.character).to.equal(0); - }); - - test('Should ignore class defined in single line comment', async () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass2.cls')); - - await vscode.workspace.openTextDocument(fileUri); - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(10, 0, 10, 1)); - - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Call findClassStartPosition method - const position = fixGenerator.findClassStartPosition(diag, doc); - - // Verify the position is correct - expect(position.line).to.equal(6); - expect(position.character).to.equal(0); - }); - - test('Should ignore class defined in a block comment comment', async () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass2.cls')); - - await vscode.workspace.openTextDocument(fileUri); - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(17, 0, 17, 1)); - - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Call findClassStartPosition method - const position = fixGenerator.findClassStartPosition(diag, doc); - - // Verify the position is correct - expect(position.line).to.equal(6); - expect(position.character).to.equal(0); - }); - - test('Should ignore class defined as a string', async () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass2.cls')); - - await vscode.workspace.openTextDocument(fileUri); - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(23, 0, 23, 1)); - - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Call findClassStartPosition method - const position = fixGenerator.findClassStartPosition(diag, doc); - - // Verify the position is correct - expect(position.line).to.equal(6); - expect(position.character).to.equal(0); - }); - test('Should ignore inner class', async () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass2.cls')); - - await vscode.workspace.openTextDocument(fileUri); - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(27, 0, 27, 1)); - - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(doc, diag); - - // Call findClassStartPosition method - const position = fixGenerator.findClassStartPosition(diag, doc); - - // Verify the position is correct - expect(position.line).to.equal(6); - expect(position.character).to.equal(0); - }); - }); - }); - suite('#generateNewSuppressionTag()', () => { - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(null, null); - - test('Should generate the correct suppression tag for Apex language', () => { - const suppressionRule = 'rule1'; - const lang = 'apex'; - const expectedSuppressionTag = `@SuppressWarnings('${suppressionRule}')\n`; - expect(fixGenerator.generateNewSuppressionTag(suppressionRule, lang)).to.equal(expectedSuppressionTag); - }); - - test('Should generate the correct suppression tag for Java language', () => { - const suppressionRule = 'rule2'; - const lang = 'java'; - const expectedSuppressionTag = `@SuppressWarnings("${suppressionRule}")\n`; - expect(fixGenerator.generateNewSuppressionTag(suppressionRule, lang)).to.equal(expectedSuppressionTag); - }); - - test('Should return an empty string for unsupported languages', () => { - const suppressionRule = 'rule3'; - const lang = 'python'; // Assuming python as an unsupported language - expect(fixGenerator.generateNewSuppressionTag(suppressionRule, lang)).to.equal(''); - }); - }); - suite('#generateUpdatedSuppressionTag()', () => { - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(null, null); - - test('Should generate the correct suppression tag for Apex language with single quotes', () => { - const updatedRules = 'rule1'; - const lang = 'apex'; - const expectedSuppressionTag = `@SuppressWarnings('${updatedRules}')`; - expect(fixGenerator.generateUpdatedSuppressionTag(updatedRules, lang)).to.equal(expectedSuppressionTag); - }); - - test('Should generate the correct suppression tag for Java language with double quotes', () => { - const updatedRules = 'rule2'; - const lang = 'java'; - const expectedSuppressionTag = `@SuppressWarnings("${updatedRules}")`; - expect(fixGenerator.generateUpdatedSuppressionTag(updatedRules, lang)).to.equal(expectedSuppressionTag); - }); - - test('Should return an empty string for unsupported languages', () => { - const updatedRules = 'rule3'; - const lang = 'python'; // Assuming python as an unsupported language - expect(fixGenerator.generateUpdatedSuppressionTag(updatedRules, lang)).to.equal(''); - }); - }); - suite('#findLineBeforeClassStartDeclaration()', () => { - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(null, null); - - test('Should find the correct line before class start declaration when it is not the first line', () => { - const classStartPosition = new vscode.Position(2, 0); - const document = { - lineAt: (lineNumber: number) => { - return { - // Simulating document content and for easy testing, line number starts at 0 - text: `This is line ${lineNumber}`, - }; - }, - } as vscode.TextDocument; - - // Call findLineBeforeClassStartDeclaration method - const line = fixGenerator.findLineBeforeClassStartDeclaration(classStartPosition, document); - - // Verify the line content is correct - expect(line).to.equal('This is line 1'); - }); - - test('Should return empty string when class declaration is the first line', () => { - const classStartPosition = new vscode.Position(0, 0); - const document = { - lineAt: (lineNumber: number) => { - return { - // Simulating document content and for easy testing, line number starts at 0 - text: `This is line ${lineNumber}`, - }; - }, - } as vscode.TextDocument; - - // Call findLineBeforeClassStartDeclaration method - const line = fixGenerator.findLineBeforeClassStartDeclaration(classStartPosition, document); - - // Verify the line content is correct - expect(line).to.equal(''); - }); - }); - suite('#isWithinQuotes()', () => { - // Instantiate our fixer - const fixGenerator: _PmdFixGenerator = new _PmdFixGenerator(null, null); - - test('Should return true if the match is within single quotes', () => { - const line = "This is 'a matching string'"; - const matchIndex = 15; // Index where match occurs within the string - const isWithin = fixGenerator.isWithinQuotes(line, matchIndex); - expect(isWithin).to.equal(true); - }); - - test('Should return true if the match is within double quotes', () => { - const line = 'This is "a matching string"'; - const matchIndex = 21; // Index where match occurs within the string - const isWithin = fixGenerator.isWithinQuotes(line, matchIndex); - expect(isWithin).to.equal(true); - }); - - test('Should return false if the match is not within quotes', () => { - const line = 'This is a line without quotes'; - const matchIndex = 5; // Index where match occurs within the string - const isWithin = fixGenerator.isWithinQuotes(line, matchIndex); - expect(isWithin).to.equal(false); - }); - - test('Should return false if the match is at the start of a string within quotes', () => { - const line = "'quotes' is at the start of a string"; - const matchIndex = 10; // Index where match occurs within the string and it is after the quotes - const isWithin = fixGenerator.isWithinQuotes(line, matchIndex); - expect(isWithin).to.equal(false); - }); - - test('Should return true if the match is at the start of a string but quotes is not closed', () => { - // This is an extreme case where someone opens a quote and has class defined in it - // and the closure of the quote is not on the same line. - const line = "'quotes is at the start of a string"; - const matchIndex = 10; // Index where match occurs within the string and it is after the quotes - const isWithin = fixGenerator.isWithinQuotes(line, matchIndex); - expect(isWithin).to.equal(true); - }); - - }); - - }); - }); - suite('Regex Pattern Tests', () => { - let fixGenerator: _PmdFixGenerator; - - setup(() => { - fixGenerator = new _PmdFixGenerator(null, null); - }); - - test('singleLineCommentPattern matches single-line comments', () => { - const pattern = fixGenerator.singleLineCommentPattern; - - // Matching cases - expect(pattern.test('// This is a single-line comment')).to.equal(true); - - // Non-matching cases - expect(pattern.test('This is not a comment')).to.equal(false); - expect(pattern.test('/* This is a block comment start */')).to.equal(false); - }); - - test('blockCommentStartPattern matches block comment starts', () => { - const pattern = fixGenerator.blockCommentStartPattern; - - // Matching cases - expect(pattern.test('/* This is a block comment start')).to.equal(true); - expect(pattern.test(' /* This is an indented block comment start')).to.equal(true); - - // Non-matching cases - expect(pattern.test('This is not a comment')).to.equal(false); - expect(pattern.test('// This is a single-line comment')).to.equal(false); - expect(pattern.test('*/ This is a block comment end')).to.equal(false); - }); - - test('blockCommentEndPattern matches block comment ends', () => { - const pattern = fixGenerator.blockCommentEndPattern; - - // Matching cases - expect(pattern.test('*/')).to.equal(true); - expect(pattern.test(' */ This is an indented block comment end')).to.equal(true); - - // Non-matching cases - expect(pattern.test('This is not a comment')).to.equal(false); - expect(pattern.test('// This is a single-line comment')).to.equal(false); - expect(pattern.test('/* This is a block comment start')).to.equal(false); - }); - - test('classDeclarationPattern matches class declarations', () => { - const pattern = fixGenerator.classDeclarationPattern; - - // Matching cases - expect(pattern.test('public class MyClass')).to.equal(true); - expect(pattern.test('final public class MyClass')).to.equal(true); - expect(pattern.test(' private static class MyClass')).to.equal(true); - - // Non-matching cases - expect(pattern.test('class="MyClass"')).to.equal(false); // HTML-like attribute - expect(pattern.test('String myClass = "some value"')).to.equal(false); - }); - - test('suppressionRegex matches @SuppressWarnings annotations', () => { - const pattern = fixGenerator.suppressionRegex; - - // Matching cases - expect(pattern.test("@SuppressWarnings('PMD.Rule')")).to.equal(true); - expect(pattern.test("@suppresswarnings('pmd.rule')")).to.equal(true); - expect(pattern.test("@suppresswarnings('PMD.Rule')")).to.equal(true); - expect(pattern.test('@SuppressWarnings("PMD.Rule")')).to.equal(true); - - // Non-matching cases - expect(pattern.test('This is not a suppression annotation')).to.equal(false); - expect(pattern.test('@SuppressWarnings')).to.equal(false); - expect(pattern.test('SuppressWarnings("PMD.Rule")')).to.equal(false); // Missing '@' - }); - }); - }); - suite('_ApexGuruFixGenerator', () => { - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass1.cls')); - suite('#generateFixes()', () => { - const processedLines = new Set(); - const fileUri = vscode.Uri.file(path.join(codeFixturesPath, 'fixer-tests', 'MyClass1.cls')); - - let doc: vscode.TextDocument; - // Load the document and store its starting contents. - setup(async () => { - doc = await vscode.workspace.openTextDocument(fileUri); - await vscode.window.showTextDocument(doc); - }); - - test('Should generate a suppression fix if line is not processed', () => { - // Create a fake diagnostic. - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(7, 4, 7, 10)); - diag.relatedInformation = [ - new vscode.DiagnosticRelatedInformation( - new vscode.Location(fileUri, new vscode.Position(0, 0)), - 'current code' - ), - new vscode.DiagnosticRelatedInformation( - new vscode.Location(fileUri, new vscode.Position(0, 0)), - 'apex guru suggested code' - ) - ]; - - // Instantiate the fixer. - const fixGenerator: _ApexGuruFixGenerator = new _ApexGuruFixGenerator(doc, diag); - - // Generate fixes. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(processedLines, doc, diag); - - // Validate results. - expect(fixes).to.have.lengthOf(1, 'One fix should be offered'); - expect(fixes[0].command.command).to.equal(Constants.QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS); - }); - - test('Should not generate a suppression fix if line is already processed', () => { - processedLines.add(7); - - // Create a fake diagnostic. - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(7, 4, 7, 10)); - diag.relatedInformation = [ - new vscode.DiagnosticRelatedInformation( - new vscode.Location(fileUri, new vscode.Position(0, 0)), - 'current code' - ), - new vscode.DiagnosticRelatedInformation( - new vscode.Location(fileUri, new vscode.Position(0, 0)), - 'apex guru suggested code' - ) - ]; - - // Instantiate the fixer. - const fixGenerator: _ApexGuruFixGenerator = new _ApexGuruFixGenerator(doc, diag); - - // Generate fixes. - const fixes: vscode.CodeAction[] = fixGenerator.generateFixes(processedLines, doc, diag); - - // Validate results. - expect(fixes).to.have.lengthOf(0, 'No fix should be offered if the line is already processed'); - }); - }); - - suite('#generateApexGuruSuppresssion()', () => { - let doc: vscode.TextDocument; - // Load the document and store its starting contents. - setup(async () => { - doc = await vscode.workspace.openTextDocument(fileUri); - await vscode.window.showTextDocument(doc); - }); - - test('Should generate the correct ApexGuru suppression code action', () => { - // Create a fake diagnostic. - const diag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( - fileUri, new vscode.Range(7, 4, 7, 10)); - diag.relatedInformation = [ - new vscode.DiagnosticRelatedInformation( - new vscode.Location(fileUri, new vscode.Position(0, 0)), - 'Some other information' - ), - new vscode.DiagnosticRelatedInformation( - new vscode.Location(fileUri, new vscode.Position(0, 0)), - 'apex guru suggested code' - ) - ]; - - // Instantiate the fixer. - const fixGenerator: _ApexGuruFixGenerator = new _ApexGuruFixGenerator(doc, diag); - - // Generate the suppression code action. - const fix = fixGenerator.generateApexGuruSuppresssion(doc); - - // Validate results. - expect(fix.command.command).to.equal(Constants.QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS); - }); - }); - }); -}); diff --git a/src/test/unit/lib/fixes-code-action-provider.test.ts b/src/test/unit/lib/fixes-code-action-provider.test.ts new file mode 100644 index 00000000..ec9240d2 --- /dev/null +++ b/src/test/unit/lib/fixes-code-action-provider.test.ts @@ -0,0 +1,152 @@ +import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts +import { createSampleCodeAnalyzerDiagnostic } from '../test-utils'; +import { createTextDocument } from "jest-mock-vscode"; +import { StubCodeActionContext } from "../vscode-stubs"; +import { FixesCodeActionProvider } from "../../../lib/fixes-code-action-provider"; +import { CodeAnalyzerDiagnostic } from "../../../lib/diagnostics"; + +const MAX_COL: number = Number.MAX_SAFE_INTEGER; + +describe('PMDSupressionsCodeActionProvider Tests', () => { + let actionProvider: FixesCodeActionProvider; + + beforeEach(() => { + actionProvider = new FixesCodeActionProvider(); + }); + + describe('provideCodeActions Tests', () => { + const sampleApexUri: vscode.Uri = vscode.Uri.file('/someFile.cls'); + const sampleApexContent: string = + `public class ConsolidatedClass {\n` + + ` public static void processAccountsAndContacts(List accounts) {\n` + + ` // Antipattern [Avoid using Schema.getGlobalDescribe() in Apex]: (has fix)\n` + + ` Schema.DescribeSObjectResult opportunityDescribe = Schema.getGlobalDescribe().get('Opportunity').getDescribe();\n` + + ` System.debug('Opportunity Describe: ' + opportunityDescribe);\n` + + `\n` + + ` for (Account acc : accounts) {\n` + + ` // Antipattern [SOQL in loop]:\n` + + ` List contacts = [SELECT Id, Email FROM Contact WHERE AccountId = :acc.Id];\n` + + ` for (Contact con : contacts) {\n` + + ` con.Email = 'newemail@example.com';\n` + + ` // Antipattern [DML in loop]:\n` + + ` update con;\n` + + ` }\n` + + ` }\n` + + `\n` + + ` // Antipattern [SOQL with negative expression]:\n` + + ` List contactsNotInUS = [SELECT Id, FirstName, LastName FROM Contact WHERE MailingCountry != 'US'];\n` + + ` System.debug('Contacts not in US: ' + contactsNotInUS);\n` + + `\n` + + ` // Antipattern [SOQL without WHERE clause or LIMIT]:\n` + + ` List allAccounts = [SELECT Id, Name FROM Account];\n` + + ` System.debug('All Accounts: ' + allAccounts);\n` + + `\n` + + ` // Antipattern [Using a list of SObjects for an IN-bind to ID in a SOQL]: (has suggestion)\n` + + ` List contactsFromAccounts = [SELECT Id, FirstName, LastName FROM Contact WHERE AccountId IN :accounts];\n` + + ` System.debug('Contacts from Accounts: ' + contactsFromAccounts);\n` + + `\n` + + ` // Antipattern [SOQL with wildcard filters]:\n` + + ` List accountsWithWildcard = [SELECT Id, Name FROM Account WHERE Name LIKE '%Corp%'];\n` + + ` System.debug('Accounts with wildcard: ' + accountsWithWildcard);\n` + + ` }\n` + + `}`; + + const sampleApexDocument: vscode.TextDocument = createTextDocument(sampleApexUri, sampleApexContent, 'apex'); + const sampleDiag1Range: vscode.Range = new vscode.Range(3, 0, 3, MAX_COL); + const sampleDiag1: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag1Range, 'AvoidUsingSchemaGetGlobalDescribe', 'apexguru'); // Note that these rule names are made up right now + sampleDiag1.relatedInformation = [ // TODO: Replace this since it was a temporary way for pilot to store before and after code for suggestions + new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), + `Schema.DescribeSObjectResult opportunityDescribe = Schema.getGlobalDescribe().get('Opportunity').getDescribe();`), + new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), + `Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();`) + ]; + const sampleDiag2Range: vscode.Range = new vscode.Range(8, 0, 8, MAX_COL); + const sampleDiag2: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag2Range, 'AvoidSOQLInLoop', 'apexguru'); + const sampleDiag3Range: vscode.Range = new vscode.Range(12, 0, 12, MAX_COL); + const sampleDiag3: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag3Range, 'AvoidDMLInLoop', 'apexguru'); + const sampleDiag4Range: vscode.Range = new vscode.Range(17, 0, 17, MAX_COL); + const sampleDiag4: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag4Range, 'AvoidSOQLWithNegativeExpression', 'apexguru'); + const sampleDiag5Range: vscode.Range = new vscode.Range(21, 0, 21, MAX_COL); + const sampleDiag5: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag5Range, 'AvoidSOQLWithoutWhereClauseOrLimit', 'apexguru'); + const sampleDiag6Range: vscode.Range = new vscode.Range(25, 0, 25, MAX_COL); + const sampleDiag6: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag6Range, 'AvoidUsingSObjectsToInBind', 'apexguru'); + sampleDiag6.relatedInformation = [ // TODO: Replace this since it was a temporary way for pilot to store before and after code for suggestions + new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), + `[SELECT Id, FirstName, LastName FROM Contact WHERE AccountId IN :accounts]`), + new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), + `Map accountsMap = new Map(accounts);\n` + + `//Inside the SOQL: convert "accounts" into "accountsMap.keySet()"`) + ]; + const sampleDiag7Range: vscode.Range = new vscode.Range(29, 0, 29, MAX_COL); + const sampleDiag7: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag7Range, 'AvoidSOQLWithWildcardFilters', 'apexguru'); + + + // TODO: This test is temporary (as it is tied to the apex guru pilot code) and will be generalized soon. + it('When selected range contains the entire document, only actions for diagnostics with apex guru suggestions are returned', () => { + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [ + sampleDiag1, sampleDiag2, sampleDiag3, sampleDiag4, sampleDiag5, sampleDiag6, sampleDiag7]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument.lineCount, 0); // select the whole file + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument, selectedRange, context); + + // We should only get 2 code actions + expect(codeActions).toHaveLength(2); + + // Validate the first one is associated with diag 1 + expect(codeActions[0].title).toEqual("Insert ApexGuru suggestions."); + expect(codeActions[0].diagnostics).toEqual([sampleDiag1]); + expect(codeActions[0].command.command).toEqual("sfca.includeApexGuruSuggestions"); + expect(codeActions[0].command.arguments).toEqual([sampleApexDocument, sampleDiag1Range.start, + `Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();\n`]) + + + // Validate the second is associated with diag 6 + expect(codeActions[1].title).toEqual("Insert ApexGuru suggestions."); + expect(codeActions[1].diagnostics).toEqual([sampleDiag6]); + expect(codeActions[1].command.command).toEqual("sfca.includeApexGuruSuggestions"); + expect(codeActions[1].command.arguments).toEqual([sampleApexDocument, sampleDiag6Range.start, + `Map accountsMap = new Map(accounts);\n` + + `//Inside the SOQL: convert "accounts" into "accountsMap.keySet()"\n`]) + }); + + it('stale diagnostics are filtered out', () => { + const staleDiag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag1Range, 'Dummy', 'apexguru'); + staleDiag.relatedInformation = [ // TODO: Replace this since it was a temporary way for pilot to store before and after code for suggestions + new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), + `before code`), + new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), + `after code`) + ]; + staleDiag.markStale(); + + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [sampleDiag1, staleDiag]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument.lineCount, 0); // select the whole file + + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument, selectedRange, context); + + expect(codeActions).toHaveLength(1); + expect(codeActions[0].diagnostics).toEqual([sampleDiag1]) + }); + + it('Diagnostics which are not CodeAnalyzerDiagnostic instances are filtered out', () => { + const diag1: vscode.Diagnostic = new vscode.Diagnostic(new vscode.Range(1, 1, 1, 3), 'dummy diag1'); + + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [diag1]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument.lineCount, 0); // select the whole file + + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument, selectedRange, context); + + expect(codeActions).toHaveLength(0); + }); + + it('Valid diagnostics not within the selected range should be filtered out', () => { + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [ + sampleDiag1, sampleDiag2, sampleDiag3, sampleDiag4, sampleDiag5, sampleDiag6, sampleDiag7]}); + const selectedRange: vscode.Range = sampleDiag2Range; // select the range of diag 2 (which isn't valid because it has no suggestions) + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument, selectedRange, context); + + expect(codeActions).toHaveLength(0); + }); + + // TODO: ADD IN MORE TESTS!! + }); +}); \ No newline at end of file diff --git a/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts b/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts new file mode 100644 index 00000000..63d29400 --- /dev/null +++ b/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts @@ -0,0 +1,196 @@ +import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts +import { createSampleCodeAnalyzerDiagnostic } from '../../test-utils'; +import { createTextDocument } from "jest-mock-vscode"; +import { PMDSupressionsCodeActionProvider } from "../../../../lib/pmd/pmd-suppressions-code-action-provider"; +import { StubCodeActionContext } from "../../vscode-stubs"; +import { CodeAnalyzerDiagnostic } from "../../../../lib/diagnostics"; + +const MAX_COL: number = Number.MAX_SAFE_INTEGER; + +describe('PMDSupressionsCodeActionProvider Tests', () => { + let actionProvider: PMDSupressionsCodeActionProvider; + + beforeEach(() => { + actionProvider = new PMDSupressionsCodeActionProvider(); + }); + + describe('provideCodeActions Tests', () => { + const sampleApexUri: vscode.Uri = vscode.Uri.file('/someFile.cls'); + + const sampleApexContent1: string = + 'public with sharing class EmptyCatchBlock {\n' + + // ↙ diag1 start (line 1, col 16) + ' public void swallowException() {\n' + + // ↖ diag1 end (line 1, col 32) + ' try {\n' + + ' insert accounts;\n' + + // ↙ diag2 start (line 4, col 10) + ' } catch (DmlException dmle) {\n' + + ' // swallowed exception\n' + + ' }\n' + + // ↖ diag2 end (line 6, col 9) + ' }\n' + + '}'; + const sampleApexDocument1: vscode.TextDocument = createTextDocument(sampleApexUri, sampleApexContent1, 'apex'); + const sampleDiag1Range: vscode.Range = new vscode.Range(1, 16, 1, 32); + const sampleDiag1: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag1Range, 'ApexDoc', 'pmd'); + const sampleDiag2Range: vscode.Range = new vscode.Range(4, 10, 6, 9); + const sampleDiag2: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag2Range, 'EmptyCatchBlock', 'pmd'); + + const sampleApexContent2: string = + `@SuppressWarnings('PMD.EmptyCatchBlock')\n` + + sampleApexContent1; + const sampleApexDocument2: vscode.TextDocument = createTextDocument(sampleApexUri, sampleApexContent2, 'apex'); + const sampleDiag3Range: vscode.Range = new vscode.Range(2, 16, 2, 32); + const sampleDiag3: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag3Range, 'ApexDoc', 'pmd'); + + + it('When a single valid pmd diagnostic is within the selected range, then 2 code action are returned - one for line level suppression and one for class level suppression', () => { + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [sampleDiag1, sampleDiag2]}); + const selectedRange: vscode.Range = sampleDiag2Range; // Only have the selection range be the diag2 range + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(2); + + // Validate the line level suppression action + expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + const lineEdits: vscode.TextEdit[] = codeActions[0].edit.get(sampleApexUri); + expect(lineEdits).toHaveLength(1); + expect(lineEdits[0].range).toEqual(new vscode.Range(4, MAX_COL, 4, MAX_COL)); + expect(lineEdits[0].newText).toEqual(" // NOPMD"); + expect(codeActions[0].command.command).toEqual("sfca.removeDiagnosticsInRange"); // TODO: This is wrong. We should only be clearing the PMD violations on this line - not all within the range + + + // Validate the class level supression action + expect(codeActions[1].title).toEqual("Suppress 'PMD.EmptyCatchBlock' on this class."); + const classEdits: vscode.TextEdit[] = codeActions[1].edit.get(sampleApexUri); + expect(classEdits).toHaveLength(1); + expect(classEdits[0].range).toEqual(new vscode.Range(0, 0, 0, 0)); + expect(classEdits[0].newText).toEqual("@SuppressWarnings('PMD.EmptyCatchBlock')\n"); + expect(codeActions[1].command.command).toEqual("sfca.removeDiagnosticsOnSelectedFile"); // TODO: This is wrong. It should only clear the PMD diagnostics within the class instead of all diagnostics within the file + }); + + it('When multiple valid pmd diagnostics on separate lines are within the selected range, then 2 code action are returned for each diagnostic', () => { + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [sampleDiag1, sampleDiag2]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument1.lineCount, 0); // select the whole file + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(4); + expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); // TODO: We should really say the line number here to avoid confusion + expect(codeActions[1].title).toEqual("Suppress 'PMD.ApexDoc' on this class."); + expect(codeActions[2].title).toEqual("Suppress all PMD violations on this line."); // TODO: We should really say the line number here to avoid confusion + expect(codeActions[3].title).toEqual("Suppress 'PMD.EmptyCatchBlock' on this class."); + }); + + it('When multiple valid pmd diagnostics are on the same line, then we only return 1 of the line suppressing diagnostics', () => { + const diag1: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, new vscode.Range(1, 1, 1, 3), 'DummyRule1', 'pmd'); + const diag2: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, new vscode.Range(1, 7, 3, 7), 'DummyRule2', 'pmd'); + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [diag1, diag2]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument1.lineCount, 0); // select the whole file + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(3); + expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + expect(codeActions[1].title).toEqual("Suppress 'PMD.DummyRule1' on this class."); + expect(codeActions[2].title).toEqual("Suppress 'PMD.DummyRule2' on this class."); + }); + + it('When a valid pmd diagnostic exists in a class that already has an existing SuppressWarning annotation, then 2 code action appends to it correctly', () => { + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [sampleDiag3]}); + const selectedRange: vscode.Range = sampleDiag3Range; // Only have the selection range be the diag3 range + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument2, selectedRange, context); + + expect(codeActions).toHaveLength(2); + + // Validate the line level suppression action + expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + const lineEdits: vscode.TextEdit[] = codeActions[0].edit.get(sampleApexUri); + expect(lineEdits).toHaveLength(1); + expect(lineEdits[0].range).toEqual(new vscode.Range(2, MAX_COL, 2, MAX_COL)); + expect(lineEdits[0].newText).toEqual(" // NOPMD"); + expect(codeActions[0].command.command).toEqual("sfca.removeDiagnosticsInRange"); // TODO: This is wrong. We should only be clearing the PMD violations on this line - not all within the range + + + // Validate the class level supression action + expect(codeActions[1].title).toEqual("Suppress 'PMD.ApexDoc' on this class."); + const classEdits: vscode.TextEdit[] = codeActions[1].edit.get(sampleApexUri); + expect(classEdits).toHaveLength(1); + expect(classEdits[0].range).toEqual(new vscode.Range(0, 0, 0, 40)); + expect(classEdits[0].newText).toEqual("@SuppressWarnings('PMD.EmptyCatchBlock, PMD.ApexDoc')"); + expect(codeActions[1].command.command).toEqual("sfca.removeDiagnosticsOnSelectedFile"); // TODO: This is wrong. It should only clear the PMD diagnostics within the class instead of all diagnostics within the file + }); + + it('When document language is not apex, then return no code actions', () => { + const sampleUri: vscode.Uri = vscode.Uri.file('/someFile.xml'); + const sampleContent: string = + // ↙ Diag start (line 0, col 0) + '\n' + + ''; + // ↖ Diag end (line 1, col 7) + + const xmlDocument: vscode.TextDocument = createTextDocument(sampleUri, sampleContent, 'xml'); // not apex + const diagRange: vscode.Range = new vscode.Range(0, 0, 1, 7); + const diag: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleUri, diagRange, 'EmptyCatchBlock', 'pmd'); + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [diag]}); + const selectedRange: vscode.Range = diagRange; + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(xmlDocument, selectedRange, context); + + expect(codeActions).toHaveLength(0); + }); + + it('diagnostics not associated with pmd engine are filtered out', () => { + const diag1: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, new vscode.Range(1, 1, 1, 3), 'DummyRule1', 'other'); + const diag2: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, new vscode.Range(2, 7, 3, 7), 'DummyRule2', 'pmd'); + + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [diag1, diag2]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument1.lineCount, 0); // select the whole file + + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(2); + expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + expect(codeActions[1].title).toEqual("Suppress 'PMD.DummyRule2' on this class."); + }); + + it('stale diagnostics are filtered out', () => { + const diag1: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, new vscode.Range(1, 1, 1, 3), 'DummyRule1', 'pmd'); + const diag2: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, new vscode.Range(2, 7, 3, 7), 'DummyRule2', 'pmd'); + diag2.markStale(); + + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [diag1, diag2]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument1.lineCount, 0); // select the whole file + + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(2); + expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + expect(codeActions[1].title).toEqual("Suppress 'PMD.DummyRule1' on this class."); + }); + + it('Diagnostics which are not CodeAnalyzerDiagnostic instances are filtered out', () => { + const diag1: vscode.Diagnostic = new vscode.Diagnostic(new vscode.Range(1, 1, 1, 3), 'dummy diag1'); + const diag2: vscode.Diagnostic = new vscode.Diagnostic(new vscode.Range(2, 1, 2, 5), 'dummy diag2'); + + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [diag1, diag2]}); + const selectedRange: vscode.Range = new vscode.Range(0, 0, sampleApexDocument1.lineCount, 0); // select the whole file + + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(0); // Also tests that if all diagnostics are filtered out we don't error + }); + + it('Valid diagnostics not within the selected range should be filtered out', () => { + const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [sampleDiag1, sampleDiag2]}); + const selectedRange: vscode.Range = new vscode.Range(2, 0, 2, MAX_COL); // does not overlap any diagnostic range + const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); + + expect(codeActions).toHaveLength(0); + }); + + // TODO: ADD IN MORE TESTS!! + // NOTE THAT THE OLD LEGACY TESTS JUST CHECKED IMPLEMENTATION DETAIL (regex patterns) SO THEY WERE REMOVED. + // BUT THE FILES test/code-fixtures/fixer-tests/*.cls STILL REMAIN BECAUSE THEY HELP TEST SOME EDGE CASES + // WHICH THE CURRENT regex BASED IMPLEMENTATION IS SENSITIVE AROUND. SO WE SHOULD ADD IN SOME EDGE CASE TESTS + // THAT USE THOSE CODE SNIPPETS WHEN WE HAVE SOME TIME TO DO SO. + }); +}); \ No newline at end of file diff --git a/src/test/unit/test-utils.ts b/src/test/unit/test-utils.ts index bea7cc2f..83ca2a5b 100644 --- a/src/test/unit/test-utils.ts +++ b/src/test/unit/test-utils.ts @@ -1,10 +1,10 @@ import * as vscode from "vscode"; import {CodeAnalyzerDiagnostic, Violation} from "../../lib/diagnostics"; -export function createSampleCodeAnalyzerDiagnostic(uri: vscode.Uri, range: vscode.Range, ruleName: string = 'someRule'): CodeAnalyzerDiagnostic { +export function createSampleCodeAnalyzerDiagnostic(uri: vscode.Uri, range: vscode.Range, ruleName: string = 'someRule', engineName: string = 'pmd'): CodeAnalyzerDiagnostic { const sampleViolation: Violation = { rule: ruleName, - engine: 'pmd', + engine: engineName, message: 'This message is unimportant', severity: 3, locations: [ diff --git a/tsconfig.json b/tsconfig.json index 37dc9280..3e55c76d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "skipLibCheck": true, /* needed to avoid conflict between @types/jest and @types/mocha. When we remove mocha, we should remove this line */ + "isolatedModules": true, "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", From 69b9fb080ead492a35b51646a2c9e2faffe8df51 Mon Sep 17 00:00:00 2001 From: Josh Feingold Date: Thu, 31 Jul 2025 16:22:54 -0500 Subject: [PATCH 03/17] NEW @W-19156628@ Release branch now updates package-lock.json (#261) --- .github/workflows/create-release-branch.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 47ac6675..1207eb89 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -91,10 +91,11 @@ jobs: MESSAGE="Preparing for v$NEW_VERSION release." # GraphQL needs the latest versions of the files we changed, as Base64 encoded strings. NEW_PACKAGE="$(cat package.json | base64)" + NEW_LOCKFILE="$(cat package-lock.json | base64)" gh api graphql -F message="$MESSAGE" -F oldOid=`git rev-parse HEAD` -F branch="$BRANCH" \ - -F newPackage="$NEW_PACKAGE" \ + -F newPackage="$NEW_PACKAGE" -F newLockfile="$NEW_LOCKFILE" \ -f query=' - mutation ($message: String!, $oldOid: GitObjectID!, $branch: String!, $newPackage: Base64String!) { + mutation ($message: String!, $oldOid: GitObjectID!, $branch: String!, $newPackage: Base64String!, $newLockfile: Base64String!) { createCommitOnBranch(input: { branch: { repositoryNameWithOwner: "forcedotcom/sfdx-code-analyzer-vscode", @@ -108,6 +109,9 @@ jobs: { path: "package.json", contents: $newPackage + }, { + path: "package-lock.json", + contents: $newLockfile } ] }, From b4aa9aa3c21f7e6d26abbe2cbbcb40393566130b Mon Sep 17 00:00:00 2001 From: Randi Wilson Date: Mon, 4 Aug 2025 16:35:18 -0400 Subject: [PATCH 04/17] CHANGE: @W-19053762@ Remove Scanner (v4) Settings Option (#262) --- package.json | 79 +------- src/extension.ts | 49 +---- src/lib/code-analyzer.ts | 22 +-- src/lib/settings.ts | 22 +-- src/test/legacy/extension.test.ts | 241 +----------------------- src/test/legacy/scanner.test.ts | 6 - src/test/unit/lib/code-analyzer.test.ts | 61 ------ src/test/unit/lib/settings.test.ts | 26 --- src/test/unit/stubs.ts | 10 - 9 files changed, 14 insertions(+), 502 deletions(-) diff --git a/package.json b/package.json index c4ec9ed0..46e6a97f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "o11yUploadEndpoint": "https://794testsite.my.site.com/byolwr/webruntime/log/metrics", "enableO11y": "true", "bugs": { - "url": "https://github.com/forcedotcom/sfdx-code-analyzer-vscode/issues" + "url": "https://github.com/forcedotcom/code-analyzer/issues" }, "repository": { "url": "https://github.com/forcedotcom/sfdx-code-analyzer-vscode" @@ -108,10 +108,6 @@ "command": "sfca.runOnSelected", "title": "SFDX: Scan Selected Files or Folders with Code Analyzer" }, - { - "command": "sfca.runDfaOnSelectedMethod", - "title": "SFDX: Scan Selected Method with Graph Engine Path-Based Analysis" - }, { "command": "sfca.removeDiagnosticsOnActiveFile", "title": "SFDX: Clear Code Analyzer Violations from Current File" @@ -120,10 +116,6 @@ "command": "sfca.removeDiagnosticsOnSelectedFile", "title": "SFDX: Clear Code Analyzer Violations from Selected Files or Folders" }, - { - "command": "sfca.runDfa", - "title": "SFDX: Scan Project with Graph Engine Path-Based Analysis" - }, { "command": "sfca.runApexGuruAnalysisOnSelectedFile", "title": "SFDX: Scan Selected File for Performance Issues with ApexGuru" @@ -151,16 +143,11 @@ "type": "boolean", "default": false, "description": "(Pilot) Discover critical problems and performance issues in your Apex code with ApexGuru, which analyzes your Apex files for you. This feature is in a closed pilot; contact your account representative to learn more." - }, - "codeAnalyzer.Use v4 (Deprecated)": { - "type": "boolean", - "default": false, - "markdownDescription": "Use Code Analyzer v4 (Deprecated) instead of Code Analyzer v5.\n\nWe no longer support Code Analyzer v4 and will soon remove this setting. We highly recommend that you use [Code Analyzer v5](https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/code-analyzer.html) instead. Selecting this setting ignores the Code Analyzer v5 settings and uses the v4 settings instead.\n\nIf you have having trouble switching to v5, create an [issue](https://github.com/forcedotcom/sfdx-code-analyzer-vscode/issues)." } } }, { - "title": "Code Analyzer v5", + "title": "Configuration", "properties": { "codeAnalyzer.configFile": { "type": "string", @@ -170,55 +157,7 @@ "codeAnalyzer.ruleSelectors": { "type": "string", "default": "Recommended", - "markdownDescription": "Selection of rules used to scan your code with Code Analyzer v5.\n\nSelect rules using their name, engine name, severity level, tag, or a combination. Use commas for unions (such as \"Security,Performance\") and colons for intersections (such as \"pmd:Security\" or \"eslint:3\").\n\nThis setting is equivalent to the `--rule-selector` flag of the CLI commands. See [examples](https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference_code-analyzer_commands_unified.htm#cli_reference_code-analyzer_rules_unified)." - } - } - }, - { - "title": "Code Analyzer v4 (Deprecated)", - "properties": { - "codeAnalyzer.pMD.customConfigFile": { - "type": "string", - "default": "", - "description": "(v4 only) Replace Code Analyzer's default PMD config file, choose a custom file." - }, - "codeAnalyzer.graphEngine.disableWarningViolations": { - "type": "boolean", - "default": false, - "description": "(v4 only) Suppress warning violations, such as those related to StripInaccessible READ access." - }, - "codeAnalyzer.graphEngine.threadTimeout": { - "type": "number", - "default": 900000, - "description": "(v4 only) After the thread timeout elapses, the path evaluation aborts. The default timeout is 900,000 milliseconds." - }, - "codeAnalyzer.graphEngine.pathExpansionLimit": { - "type": "number", - "default": 0, - "description": "(v4 only) An upper boundary to limit the complexity of code analyzed by Graph Engine. The default of 0 is a dynamically determined limit that's based on heap space. To learn more about heap space, see OutOfMemory errors in the Graph Engine documentation." - }, - "codeAnalyzer.graphEngine.jvmArgs": { - "type": "string", - "description": "(v4 only) The Java Virtual Machine (JVM) arguments used to optimize Salesforce Graph Engine execution for your system." - }, - "codeAnalyzer.scanner.engines": { - "type": "string", - "default": "pmd,retire-js,eslint-lwc", - "description": "(v4 only) The engines to run. Specify multiple values as a comma-separated list. Possible values are pmd, pmd-appexchange, retire-js, eslint, eslint-lwc, and eslint-typescript." - }, - "codeAnalyzer.normalizeSeverity.enabled": { - "type": "boolean", - "default": false, - "description": "(v4 only) Output normalized severity (high, moderate, low) and engine-specific severity across all engines." - }, - "codeAnalyzer.rules.category": { - "type": "string", - "description": "(v4 only) The categories of rules to run. Specify multiple values as a comma-separated list. Run 'sf scanner rule list -e' in the terminal for the list of categories associated with a specific engine." - }, - "codeAnalyzer.partialGraphEngineScans.enabled": { - "type": "boolean", - "default": false, - "description": "(v4 only) Enables partial Salesforce Graph Engine scans on only the code you've modified since the initial full scan. (Beta)" + "markdownDescription": "Selection of rules used to scan your code with Code Analyzer.\n\nSelect rules using their name, engine name, severity level, tag, or a combination. Use commas for unions (such as \"Security,Performance\") and colons for intersections (such as \"pmd:Security\" or \"eslint:3\").\n\nThis setting is equivalent to the `--rule-selector` flag of the CLI commands. See [examples](https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference_code-analyzer_commands_unified.htm#cli_reference_code-analyzer_rules_unified)." } } } @@ -233,14 +172,6 @@ "command": "sfca.runOnSelected", "when": "false" }, - { - "command": "sfca.runDfaOnSelectedMethod", - "when": "false" - }, - { - "command": "sfca.runDfa", - "when": "sfca.extensionActivated && sfca.partialRunsEnabled && sfca.codeAnalyzerV4Enabled" - }, { "command": "sfca.removeDiagnosticsOnActiveFile", "when": "sfca.extensionActivated" @@ -263,10 +194,6 @@ "command": "sfca.runOnActiveFile", "when": "sfca.extensionActivated" }, - { - "command": "sfca.runDfaOnSelectedMethod", - "when": "sfca.extensionActivated && sfca.codeAnalyzerV4Enabled" - }, { "command": "sfca.removeDiagnosticsOnActiveFile", "when": "sfca.extensionActivated" diff --git a/src/extension.ts b/src/extension.ts index 1d057349..f9369c15 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -111,18 +111,8 @@ export async function activate(context: vscode.ExtensionContext): Promise Promise.resolve(settingsManager.getCodeAnalyzerUseV4Deprecated())); - - // Monitor the "codeAnalyzer.Use v4 (Deprecated)" setting with telemetry - vscode.workspace.onDidChangeConfiguration((event: vscode.ConfigurationChangeEvent) => { - if (event.affectsConfiguration('codeAnalyzer.Use v4 (Deprecated)')) { - telemetryService.sendCommandEvent(Constants.TELEM_SETTING_USEV4, { - value: settingsManager.getCodeAnalyzerUseV4Deprecated().toString()}); - } - }); - // COMMAND_RUN_ON_ACTIVE_FILE: Invokable by 'commandPalette' and 'editor/context' menu always. Uses v4 instead of v5 when 'sfca.codeAnalyzerV4Enabled'. + // COMMAND_RUN_ON_ACTIVE_FILE: Invokable by 'commandPalette' and 'editor/context' menu always. registerCommand(Constants.COMMAND_RUN_ON_ACTIVE_FILE, async () => { const document: vscode.TextDocument = await getActiveDocument(); if (document === null) { @@ -213,32 +203,6 @@ export async function activate(context: vscode.ExtensionContext): Promise diagnosticManager.clearDiagnosticsInRange(uri, range)); - // ================================================================================================================= - // == DFA Run Functionality - // ================================================================================================================= - - // It is possible that the cache was not cleared when VS Code exited the last time. Just to be on the safe side, we clear the DFA process cache at activation. - void context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - await establishVariableInContext(Constants.CONTEXT_VAR_PARTIAL_RUNS_ENABLED, - () => Promise.resolve(settingsManager.getSfgePartialSfgeRunsEnabled())); - - // COMMAND_RUN_DFA_ON_SELECTED_METHOD: Invokable by 'editor/context' only when "sfca.codeAnalyzerV4Enabled" - registerCommand(Constants.COMMAND_RUN_DFA_ON_SELECTED_METHOD, async () => { - if (await dfaRunner.shouldProceedWithDfaRun()) { - const methodLevelTarget: string[] = [await targeting.getSelectedMethod()]; - await dfaRunner.runMethodLevelDfa(methodLevelTarget); - } - }); - - // COMMAND_RUN_DFA: Invokable by 'commandPalette' only when "sfca.partialRunsEnabled && sfca.codeAnalyzerV4Enabled" - registerCommand(Constants.COMMAND_RUN_DFA, async () => { - await dfaRunner.runDfa(); - dfaRunner.clearSavedFilesCache(); - }); - - onDidSaveTextDocument((document: vscode.TextDocument) => dfaRunner.addSavedFileToCache(document.fileName)); - - // ================================================================================================================= // == Apex Guru Integration Functionality // ================================================================================================================= @@ -315,17 +279,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { - if (selection === messages.buttons.startUsingV5) { - settingsManager.setCodeAnalyzerUseV4Deprecated(false); - } else if (selection === messages.buttons.showSettings) { - const settingUri = vscode.Uri.parse('vscode://settings/codeAnalyzer.Use v4 (Deprecated)'); - vscode.commands.executeCommand(Constants.VSCODE_COMMAND_OPEN_URL, settingUri); - } - }); - } - telemetryService.sendExtensionActivationEvent(extensionHrStart); await vscode.commands.executeCommand('setContext', Constants.CONTEXT_VAR_EXTENSION_ACTIVATED, true); logger.log('Extension sfdx-code-analyzer-vscode activated.'); diff --git a/src/lib/code-analyzer.ts b/src/lib/code-analyzer.ts index 32aa3332..6fadc41a 100644 --- a/src/lib/code-analyzer.ts +++ b/src/lib/code-analyzer.ts @@ -1,5 +1,4 @@ import {Violation} from "./diagnostics"; -import {CliScannerV4Strategy} from "./scanner-strategies/v4-scanner"; import {CliScannerV5Strategy} from "./scanner-strategies/v5-scanner"; import {SettingsManager} from "./settings"; import {Display} from "./display"; @@ -26,7 +25,6 @@ export class CodeAnalyzerImpl implements CodeAnalyzer { private cliIsInstalled: boolean = false; - private codeAnalyzerV4?: CliScannerV4Strategy; private codeAnalyzerV5?: CliScannerV5Strategy; constructor(cliCommandExecutor: CliCommandExecutor, settingsManager: SettingsManager, display: Display, @@ -44,28 +42,12 @@ export class CodeAnalyzerImpl implements CodeAnalyzer { } this.cliIsInstalled = true; } - if (this.settingsManager.getCodeAnalyzerUseV4Deprecated()) { - await this.validateV4Plugin(); - } else { - await this.validateV5Plugin(); - } + await this.validateV5Plugin(); } private async getDelegate(): Promise { await this.validateEnvironment(); - return this.settingsManager.getCodeAnalyzerUseV4Deprecated() ? this.codeAnalyzerV4 : this.codeAnalyzerV5; - } - - private async validateV4Plugin(): Promise { - if (this.codeAnalyzerV4 !== undefined) { - return; // Already validated - } - // Even though v4 is a JIT plugin... in the future it might not be. So we validate for future proofing. - const installedVersion: semver.SemVer | undefined = await this.cliCommandExecutor.getSfCliPluginVersion('@salesforce/sfdx-scanner'); - if (!installedVersion) { - throw new Error(messages.error.sfdxScannerMissing); - } - this.codeAnalyzerV4 = new CliScannerV4Strategy(installedVersion, this.cliCommandExecutor, this.settingsManager, this.fileHandler); + return this.codeAnalyzerV5; } private async validateV5Plugin(): Promise { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a981417b..f0599665 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -11,8 +11,6 @@ export interface SettingsManager { getAnalyzeOnOpen(): boolean; getAnalyzeOnSave(): boolean; getApexGuruEnabled(): boolean; - getCodeAnalyzerUseV4Deprecated(): boolean; - setCodeAnalyzerUseV4Deprecated(value: boolean): void; // v5 Settings getCodeAnalyzerConfigFile(): string; @@ -49,26 +47,8 @@ export class SettingsManagerImpl implements SettingsManager { return vscode.workspace.getConfiguration('codeAnalyzer.apexGuru').get('enabled'); } - public getCodeAnalyzerUseV4Deprecated(): boolean { - return vscode.workspace.getConfiguration('codeAnalyzer').get('Use v4 (Deprecated)'); - } - - /** - * Sets the 'Use v4 (Deprecated)' value at the user (global) level and removes the setting at all other levels - */ - public setCodeAnalyzerUseV4Deprecated(value: boolean): void { - void vscode.workspace.getConfiguration('codeAnalyzer').update('Use v4 (Deprecated)', value, vscode.ConfigurationTarget.Global); - - // If there is a workspace open (which is true if workspaceFolders is nonempty), then we should update the workspace settings - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - void vscode.workspace.getConfiguration('codeAnalyzer').update('Use v4 (Deprecated)', undefined, vscode.ConfigurationTarget.Workspace); - void vscode.workspace.getConfiguration('codeAnalyzer').update('Use v4 (Deprecated)', undefined, vscode.ConfigurationTarget.WorkspaceFolder); - } - } - - // ================================================================================================================= - // ==== v5 Settings + // ==== Configuration Settings // ================================================================================================================= public getCodeAnalyzerConfigFile(): string { return vscode.workspace.getConfiguration('codeAnalyzer').get('configFile'); diff --git a/src/test/legacy/extension.test.ts b/src/test/legacy/extension.test.ts index ca6b5e34..ce14a327 100644 --- a/src/test/legacy/extension.test.ts +++ b/src/test/legacy/extension.test.ts @@ -15,23 +15,19 @@ import { SFCAExtensionData } from '../../extension'; import {messages} from '../../lib/messages'; -import {SettingsManager, SettingsManagerImpl} from '../../lib/settings'; +import {SettingsManagerImpl} from '../../lib/settings'; import * as Constants from '../../lib/constants'; import * as targeting from '../../lib/targeting'; import * as vscode from 'vscode'; import {DiagnosticManager, DiagnosticManagerImpl} from '../../lib/diagnostics'; import {SpyLogger, StubTelemetryService} from "./test-utils"; -import {DfaRunner} from "../../lib/dfa-runner"; import {CodeAnalyzerRunAction} from "../../lib/code-analyzer-run-action"; import {CodeAnalyzer, CodeAnalyzerImpl} from "../../lib/code-analyzer"; import {TaskWithProgressRunner, TaskWithProgressRunnerImpl} from "../../lib/progress"; import {Display, VSCodeDisplay} from "../../lib/display"; -import {CliCommandExecutorImpl} from "../../lib/cli-commands"; -import {Logger} from "../../lib/logger"; import { SpyWindowManager, StubFileHandler, - StubSettingsManager, StubSpyCliCommandExecutor, StubVscodeWorkspace } from "../unit/stubs"; @@ -82,10 +78,7 @@ suite('Extension Test Suite', () => { Sinon.restore(); }); - async function runTest(desiredV5EnablementStatus: boolean): Promise { - // ===== SETUP ===== - // Set V5's enablement to the desired state. - Sinon.stub(SettingsManagerImpl.prototype, 'getCodeAnalyzerUseV4Deprecated').returns(!desiredV5EnablementStatus); + async function runTest(): Promise { // ===== TEST ===== // Run the "scan active file" command. @@ -105,14 +98,9 @@ suite('Extension Test Suite', () => { } } - test('Adds proper diagnostics when running with v4', async function() { - this.timeout(90000); - await runTest(false); - }); - test('Adds proper diagnostics when running with v5', async function() { this.timeout(90000); - await runTest(true); + await runTest(); }); }); @@ -125,11 +113,7 @@ suite('Extension Test Suite', () => { Sinon.restore(); }); - async function runTest(desiredV5EnablementStatus: boolean): Promise { - // ===== SETUP ===== - // Set V5's enablement to the desired state. - Sinon.stub(SettingsManagerImpl.prototype, 'getCodeAnalyzerUseV4Deprecated').returns(!desiredV5EnablementStatus); - + async function runTest(): Promise { // ===== TEST ===== // Run the "scan selected files" command. // Pass the URI in as the first parameter, since that's what happens on a single-file selection. @@ -146,21 +130,13 @@ suite('Extension Test Suite', () => { } } - test('Adds proper diagnostics when running with v4', async function() { - this.timeout(90000); - await runTest(false); - }); - test('Adds proper diagnostics when running with v5', async function() { this.timeout(90000); - await runTest(true); + await runTest(); }); }); suite('One folder selected', () => { - test('Adds proper diagnostics when running with v4', async function() { - // TODO: WRITE THIS TEST - }); test('Adds proper diagnostics when running with v5', async function() { // TODO: WRITE THIS TEST @@ -176,11 +152,7 @@ suite('Extension Test Suite', () => { Sinon.restore(); }); - async function runTest(desiredV5EnablementStatus: boolean): Promise { - // ===== SETUP ===== - // Set V5's enablement to the desired state. - Sinon.stub(SettingsManagerImpl.prototype, 'getCodeAnalyzerUseV4Deprecated').returns(!desiredV5EnablementStatus); - + async function runTest(): Promise { // ===== TEST ===== // Run the "scan selected files" command. // Pass the URIs in as the second parameter, since that's what happens on a multi-select pick. @@ -200,21 +172,13 @@ suite('Extension Test Suite', () => { } } - test('Adds proper diagnostics when running with v4', async function() { - this.timeout(90000); - await runTest(false); - }); - test('Adds proper diagnostics when running with v5', async function() { this.timeout(90000); - await runTest(true); + await runTest(); }); }); }); - test('sfca.runDfaOnSelected', async () => { - // TODO: Add actual tests for `runDfaOnSelected`. - }); }); suite('#_runAndDisplay()', () => { @@ -272,197 +236,6 @@ suite('Extension Test Suite', () => { }); }); - suite('#_runAndDisplayDfa()', () => { - let settingsManager: SettingsManager; - - setup(() => { - settingsManager = new StubSettingsManager(); - settingsManager.setCodeAnalyzerUseV4Deprecated(true); - }); - - teardown(() => { - settingsManager.setCodeAnalyzerUseV4Deprecated(false); - }); - - suite('Error handling', () => { - teardown(() => { - Sinon.restore(); - }); - - test('Throws error if `sf` is missing', async function () { - this.timeout(90000); - - // ===== SETUP ===== - const stubTelemetryService: StubTelemetryService = new StubTelemetryService(); - // Simulate SF being unavailable. - const errorSpy = Sinon.spy(vscode.window, 'showErrorMessage'); - const cliCommandExecutor: StubSpyCliCommandExecutor = new StubSpyCliCommandExecutor(); - cliCommandExecutor.isSfInstalledReturnValue = false; - const fakeTelemetryName = 'FakeName'; - - const context: vscode.ExtensionContext = null; // Not needed for this test, so just setting it to null - const logger: Logger = new SpyLogger(); - const codeAnalyzer: CodeAnalyzer = new CodeAnalyzerImpl(cliCommandExecutor, settingsManager, new VSCodeDisplay(logger)); - const dfaRunner: DfaRunner = new DfaRunner(context, codeAnalyzer, stubTelemetryService, logger) - - // ===== TEST ===== - // Attempt to run the appropriate extension command. - await dfaRunner._runAndDisplayDfa(fakeTelemetryName, null, ['someMethod'], 'some/project/dir'); - - // ===== ASSERTIONS ===== - Sinon.assert.callCount(errorSpy, 1); - expect(errorSpy.firstCall.args[0]).to.include(messages.error.sfMissing); - const sentExceptions = stubTelemetryService.getSentExceptions(); - expect(sentExceptions.length).to.equal(1, 'Wrong number of exceptions sent'); - expect(sentExceptions[0].name).to.equal(Constants.TELEM_FAILED_DFA_ANALYSIS, 'Wrong telemetry key'); - expect(sentExceptions[0].message).to.include(messages.error.sfMissing); - expect(sentExceptions[0].data).to.haveOwnProperty('executedCommand', fakeTelemetryName, 'Wrong command name applied'); - }); - - test('Throws error if `sfdx-scanner` is missing', async function () { - this.timeout(90000); - - // ===== SETUP ===== - const stubTelemetryService: StubTelemetryService = new StubTelemetryService(); - // Simulate SF being available but SFDX Scanner being absent. - const errorSpy = Sinon.spy(vscode.window, 'showErrorMessage'); - const cliCommandExecutor: StubSpyCliCommandExecutor = new StubSpyCliCommandExecutor(); - cliCommandExecutor.isSfInstalledReturnValue = true; - cliCommandExecutor.getSfCliPluginVersionReturnValue = null; - const fakeTelemetryName = 'FakeName'; - - const context: vscode.ExtensionContext = null; // Not needed for this test, so just setting it to null - const logger: Logger = new SpyLogger(); - const codeAnalyzer: CodeAnalyzer = new CodeAnalyzerImpl(cliCommandExecutor, settingsManager, new VSCodeDisplay(logger)); - const dfaRunner: DfaRunner = new DfaRunner(context, codeAnalyzer, stubTelemetryService, logger) - - // ===== TEST ===== - try { - await dfaRunner._runAndDisplayDfa(fakeTelemetryName, null, ['someMethod'], 'some/project/dir'); - } catch (_e) { - // Spy will check the error - } - - // ===== ASSERTIONS ===== - Sinon.assert.callCount(errorSpy, 1); - expect(errorSpy.firstCall.args[0]).to.include(messages.error.sfdxScannerMissing); - const sentExceptions = stubTelemetryService.getSentExceptions(); - expect(sentExceptions.length).to.equal(1, 'Wrong number of exceptions'); - expect(sentExceptions[0].name).to.equal(Constants.TELEM_FAILED_DFA_ANALYSIS, 'Wrong telemetry key'); - expect(sentExceptions[0].message).to.include(messages.error.sfdxScannerMissing); - expect(sentExceptions[0].data).to.haveOwnProperty('executedCommand', fakeTelemetryName, 'Wrong command name applied'); - }); - }); - }); - - suite('#_shouldProceedWithDfaRun()', () => { - let settingsManager: SettingsManager; - - setup(() => { - settingsManager = new SettingsManagerImpl(); - settingsManager.setCodeAnalyzerUseV4Deprecated(true); - }); - - const ext: vscode.Extension = vscode.extensions.getExtension('salesforce.sfdx-code-analyzer-vscode'); - let context: vscode.ExtensionContext; - - suiteSetup(async function () { - // Activate the extension. - const extData: SFCAExtensionData = await ext.activate(); - context = extData.context; - }); - - teardown(async () => { - Sinon.restore(); - await context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - settingsManager.setCodeAnalyzerUseV4Deprecated(false); - }); - - test('Returns true and confirmation message not called when no existing DFA process detected', async function () { - this.timeout(90000); - - const infoMessageSpy = Sinon.spy(vscode.window, 'showInformationMessage'); - - await context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - - const logger: Logger = new SpyLogger(); - const codeAnalyzer: CodeAnalyzer = new CodeAnalyzerImpl(new CliCommandExecutorImpl(new SpyLogger()), settingsManager, new VSCodeDisplay(logger)); - const dfaRunner: DfaRunner = new DfaRunner(context, codeAnalyzer, new StubTelemetryService(), logger) - - expect(await dfaRunner.shouldProceedWithDfaRun()).to.equal(true); - Sinon.assert.callCount(infoMessageSpy, 0); - }); - - test('Confirmation message called when DFA process detected', async function () { - this.timeout(90000); - - const infoMessageSpy = Sinon.spy(vscode.window, 'showInformationMessage'); - await context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, 1234); - - const logger: Logger = new SpyLogger(); - const codeAnalyzer: CodeAnalyzer = new CodeAnalyzerImpl(new CliCommandExecutorImpl(new SpyLogger()), - settingsManager, new VSCodeDisplay(logger)); - const dfaRunner: DfaRunner = new DfaRunner(context, codeAnalyzer, new StubTelemetryService(), logger) - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - dfaRunner.shouldProceedWithDfaRun(); - - Sinon.assert.callCount(infoMessageSpy, 1); - expect(infoMessageSpy.firstCall.args[0]).to.include(messages.graphEngine.existingDfaRunText); - }); - }); - - suite('#_stopExistingDfaRun()', function () { - - let settingsManager: SettingsManager; - - setup(() => { - settingsManager = new SettingsManagerImpl(); - settingsManager.setCodeAnalyzerUseV4Deprecated(true); - }); - - const ext: vscode.Extension = vscode.extensions.getExtension('salesforce.sfdx-code-analyzer-vscode'); - let context: vscode.ExtensionContext; - - suiteSetup(async () => { - // Activate the extension. - const extData: SFCAExtensionData = await ext.activate(); - context = extData.context; - }); - - teardown(() => { - void context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - Sinon.restore(); - settingsManager.setCodeAnalyzerUseV4Deprecated(false); - }); - - test('Cache cleared as part of stopping the existing DFA run', async function () { - this.timeout(90000); - - context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, 1234); - - const logger: Logger = new SpyLogger(); - const codeAnalyzer: CodeAnalyzer = new CodeAnalyzerImpl(new CliCommandExecutorImpl(logger), settingsManager, new VSCodeDisplay(logger)); - const dfaRunner: DfaRunner = new DfaRunner(context, codeAnalyzer, new StubTelemetryService(), logger) - - await dfaRunner.stopExistingDfaRun(); - expect(context.workspaceState.get(Constants.WORKSPACE_DFA_PROCESS)).to.be.undefined; - }); - - test('Cache stays cleared when there are no existing DFA runs', function () { - this.timeout(90000); - - void context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - const logger: Logger = new SpyLogger(); - const codeAnalyzer: CodeAnalyzer = new CodeAnalyzerImpl(new CliCommandExecutorImpl(logger), settingsManager, new VSCodeDisplay(logger)); - const dfaRunner: DfaRunner = new DfaRunner(context, codeAnalyzer, new StubTelemetryService(), logger) - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - dfaRunner.stopExistingDfaRun(); - expect(context.workspaceState.get(Constants.WORKSPACE_DFA_PROCESS)).to.be.undefined; - }); - }); - suite('#isValidFileForAnalysis', () => { test('Returns true for valid files', () => { // ===== SETUP ===== and ===== ASSERTIONS ===== diff --git a/src/test/legacy/scanner.test.ts b/src/test/legacy/scanner.test.ts index c64eb3aa..4d1d1f68 100644 --- a/src/test/legacy/scanner.test.ts +++ b/src/test/legacy/scanner.test.ts @@ -370,12 +370,6 @@ class StubSettingsManager implements SettingsManager { this.resetSettings(); } - getCodeAnalyzerUseV4Deprecated(): boolean { - throw new Error('Method not implemented.'); - } - setCodeAnalyzerUseV4Deprecated(_value: boolean): void { - throw new Error('Method not implemented.'); - } getCodeAnalyzerConfigFile(): string | undefined { throw new Error('Method not implemented.'); } diff --git a/src/test/unit/lib/code-analyzer.test.ts b/src/test/unit/lib/code-analyzer.test.ts index 6fe43ecb..101b4764 100644 --- a/src/test/unit/lib/code-analyzer.test.ts +++ b/src/test/unit/lib/code-analyzer.test.ts @@ -5,7 +5,6 @@ import {messages} from "../../../lib/messages"; import {Violation} from "../../../lib/diagnostics"; import * as path from "path"; import {Workspace} from "../../../lib/workspace"; -import {StubVscodeWorkspace} from "../stubs"; const TEST_DATA_DIR: string = path.resolve(__dirname, '..', 'test-data'); @@ -276,64 +275,4 @@ describe('Tests for the CodeAnalyzerImpl class', () => { }); }); - describe('When using the "Use v4 (Deprecated)" setting ...', () => { - beforeEach(() => { - settingsManager.getCodeAnalyzerUseV4DeprecatedReturnValue = true; - }); - - describe('v4 tests for the validateEnvironment method', () => { - it('When the Salesforce CLI is not installed, then error', async () => { - cliCommandExecutor.isSfInstalledReturnValue = false; - await expect(codeAnalyzer.validateEnvironment()).rejects.toThrow(messages.error.sfMissing); - }); - - it('When the scanner plugin is not installed, then error', async () => { - cliCommandExecutor.getSfCliPluginVersionReturnValue = undefined; - await expect(codeAnalyzer.validateEnvironment()).rejects.toThrow(messages.error.sfdxScannerMissing); - }); - - it('When the scanner plugin is installed, then no error and no warning', async () => { - cliCommandExecutor.getSfCliPluginVersionReturnValue = new semver.SemVer('4.9.0'); - await codeAnalyzer.validateEnvironment(); - expect(display.displayErrorCallHistory).toHaveLength(0); - expect(display.displayWarningCallHistory).toHaveLength(0); - }); - }); - - describe('v4 tests for the getScannerName method', () => { - it('Sanity check that getScannerName first calls validateEnvironment', async () => { - cliCommandExecutor.isSfInstalledReturnValue = false; - await expect(codeAnalyzer.getScannerName()).rejects.toThrow(messages.error.sfMissing); - }); - - it('When he scanner name reflects the v4 version', async () => { - settingsManager.getCodeAnalyzerUseV4DeprecatedReturnValue = true; - cliCommandExecutor.getSfCliPluginVersionReturnValue = new semver.SemVer('4.5.0'); - const scannerName: string = await codeAnalyzer.getScannerName(); - expect(scannerName).toEqual('@salesforce/sfdx-scanner@4.5.0 via CLI'); - }); - }); - - describe('v4 tests for the scan method', () => { - it('Sanity check that scan first calls validateEnvironment', async () => { - cliCommandExecutor.isSfInstalledReturnValue = false; - const workspace: Workspace = await Workspace.fromTargetPaths([], new StubVscodeWorkspace(), fileHandler); - await expect(codeAnalyzer.scan(workspace)).rejects.toThrow(messages.error.sfMissing); - }); - - // TODO: More tests coming soon ... - }); - - describe('v4 tests for the getRuleDescriptionFor method', () => { - it('Sanity check that getRuleDescriptionFor first calls validateEnvironment', async () => { - cliCommandExecutor.isSfInstalledReturnValue = false; - await expect(codeAnalyzer.getRuleDescriptionFor('someEngine','someRule')).rejects.toThrow(messages.error.sfMissing); - }); - - it('When getRuleDescriptionFor is called, then it always just returns empty since this is bonus functionality for A4D', async () => { - const description: string = await codeAnalyzer.getRuleDescriptionFor('pmd', 'ApexDoc'); - expect(description).toEqual(''); - }); - }); - }); }); diff --git a/src/test/unit/lib/settings.test.ts b/src/test/unit/lib/settings.test.ts index 3233781c..01d49be1 100644 --- a/src/test/unit/lib/settings.test.ts +++ b/src/test/unit/lib/settings.test.ts @@ -44,32 +44,6 @@ describe('Tests for the SettingsManagerImpl class ', () => { expect(getMock).toHaveBeenCalledWith('enabled'); }); - it('should get useV4Deprecated', () => { - getMock.mockReturnValue(false); - expect(settingsManager.getCodeAnalyzerUseV4Deprecated()).toBe(false); - expect(getMock).toHaveBeenCalledWith('Use v4 (Deprecated)'); - }); - - it('should set useV4Deprecated and remove it at global level', () => { - settingsManager.setCodeAnalyzerUseV4Deprecated(true); - expect(updateMock).toHaveBeenNthCalledWith(1, 'Use v4 (Deprecated)', true, vscode.ConfigurationTarget.Global); - expect(updateMock).not.toHaveBeenNthCalledWith(2, 'Use v4 (Deprecated)', undefined, vscode.ConfigurationTarget.Workspace); - expect(updateMock).not.toHaveBeenNthCalledWith(3, 'Use v4 (Deprecated)', undefined, vscode.ConfigurationTarget.WorkspaceFolder); - }); - - it('should set useV4Deprecated and remove it at workspace levels when workspace folder exists', () => { - const someFolder: vscode.WorkspaceFolder = { - uri: vscode.Uri.file('/some/file'), - name: 'someName', - index: 0 - }; - jest.spyOn(vscode.workspace, 'workspaceFolders', 'get').mockReturnValue([someFolder]); // Simulate that workspace is open - - settingsManager.setCodeAnalyzerUseV4Deprecated(true); - expect(updateMock).toHaveBeenNthCalledWith(1, 'Use v4 (Deprecated)', true, vscode.ConfigurationTarget.Global); - expect(updateMock).toHaveBeenNthCalledWith(2, 'Use v4 (Deprecated)', undefined, vscode.ConfigurationTarget.Workspace); - expect(updateMock).toHaveBeenNthCalledWith(3, 'Use v4 (Deprecated)', undefined, vscode.ConfigurationTarget.WorkspaceFolder); - }); }); describe('v5 Settings', () => { diff --git a/src/test/unit/stubs.ts b/src/test/unit/stubs.ts index 4d774789..fa62f577 100644 --- a/src/test/unit/stubs.ts +++ b/src/test/unit/stubs.ts @@ -272,16 +272,6 @@ export class StubSettingsManager implements SettingsManager { return this.getApexGuruEnabledReturnValue; } - getCodeAnalyzerUseV4DeprecatedReturnValue: boolean = false; - - getCodeAnalyzerUseV4Deprecated(): boolean { - return this.getCodeAnalyzerUseV4DeprecatedReturnValue; - } - - setCodeAnalyzerUseV4Deprecated(value: boolean): void { - this.getCodeAnalyzerUseV4DeprecatedReturnValue = value; - } - // ================================================================================================================= // ==== v5 Settings // ================================================================================================================= From 76a352810ee42d74bc7adf1113291759c1146fb3 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:51:15 -0400 Subject: [PATCH 05/17] NEW: @W-19160212@: Unify quick fix experience and prepare ApexGuru violations to use it (#263) --- jest.config.mjs | 2 +- src/extension.ts | 76 +++-- ...provider.ts => a4d-fix-action-provider.ts} | 44 ++- src/lib/agentforce/a4d-fix-action.ts | 217 ++++++------- .../agentforce/agentforce-violation-fixer.ts | 92 ------ src/lib/apexguru/apex-guru-service.ts | 59 +++- .../apply-violation-fixes-action-provider.ts | 43 +++ src/lib/apply-violation-fixes-action.ts | 70 ++++ src/lib/constants.ts | 19 +- src/lib/diagnostics.ts | 54 +++- src/lib/fix-suggestion.ts | 5 - src/lib/fixes-code-action-provider.ts | 56 ---- src/lib/messages.ts | 16 +- .../pmd-suppressions-code-action-provider.ts | 2 +- src/lib/suggest-fix-with-diff-action.ts | 146 +++++++++ .../legacy/apexguru/apex-guru-service.test.ts | 51 +-- .../lib/agentforce/a4d-fix-action.test.ts | 304 ++++++++++++++---- .../agentforce-code-action-provider.test.ts | 26 +- .../agentforce-violation-fixer.test.ts | 131 -------- ...y-violation-fixes-action-provider.test.ts} | 102 +++--- .../lib/apply-violation-fixes-action.test.ts | 216 +++++++++++++ src/test/unit/lib/diagnostics.test.ts | 6 +- ...-suppressions-code-action-provider.test.ts | 30 +- src/test/unit/stubs.ts | 20 +- src/test/unit/test-utils.ts | 38 ++- 25 files changed, 1089 insertions(+), 736 deletions(-) rename src/lib/agentforce/{agentforce-code-action-provider.ts => a4d-fix-action-provider.ts} (59%) delete mode 100644 src/lib/agentforce/agentforce-violation-fixer.ts create mode 100644 src/lib/apply-violation-fixes-action-provider.ts create mode 100644 src/lib/apply-violation-fixes-action.ts delete mode 100644 src/lib/fixes-code-action-provider.ts create mode 100644 src/lib/suggest-fix-with-diff-action.ts delete mode 100644 src/test/unit/lib/agentforce/agentforce-violation-fixer.test.ts rename src/test/unit/lib/{fixes-code-action-provider.test.ts => apply-violation-fixes-action-provider.test.ts} (54%) create mode 100644 src/test/unit/lib/apply-violation-fixes-action.test.ts diff --git a/jest.config.mjs b/jest.config.mjs index 8da51388..892dc079 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -5,7 +5,7 @@ const config = { testMatch: ['**/*.test.ts'], collectCoverage: true, collectCoverageFrom: [ - 'src/**/*.ts', + './src/**/*.ts', ], coveragePathIgnorePatterns: [ '/src/test/', diff --git a/src/extension.ts b/src/extension.ts index f9369c15..17d4f1ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -15,13 +15,12 @@ import {CoreExtensionService} from './lib/core-extension-service'; import * as Constants from './lib/constants'; import * as path from 'path'; import * as ApexGuruFunctions from './lib/apexguru/apex-guru-service'; -import {AgentforceViolationFixer} from './lib/agentforce/agentforce-violation-fixer' import {ExternalServiceProvider} from "./lib/external-services/external-service-provider"; import {Logger, LoggerImpl} from "./lib/logger"; import {TelemetryService} from "./lib/external-services/telemetry-service"; import {DfaRunner} from "./lib/dfa-runner"; import {CodeAnalyzerRunAction} from "./lib/code-analyzer-run-action"; -import {AgentforceCodeActionProvider} from "./lib/agentforce/agentforce-code-action-provider"; +import {A4DFixActionProvider} from "./lib/agentforce/a4d-fix-action-provider"; import {ScanManager} from './lib/scan-manager'; import {A4DFixAction} from './lib/agentforce/a4d-fix-action'; import {UnifiedDiffService, UnifiedDiffServiceImpl} from "./lib/unified-diff-service"; @@ -33,8 +32,9 @@ import {getErrorMessage} from "./lib/utils"; import {FileHandler, FileHandlerImpl} from "./lib/fs-utils"; import {VscodeWorkspace, VscodeWorkspaceImpl, WindowManager, WindowManagerImpl} from "./lib/vscode-api"; import {Workspace} from "./lib/workspace"; -import { PMDSupressionsCodeActionProvider } from './lib/pmd/pmd-suppressions-code-action-provider'; -import { FixesCodeActionProvider } from './lib/fixes-code-action-provider'; +import {PMDSupressionsCodeActionProvider} from './lib/pmd/pmd-suppressions-code-action-provider'; +import {ApplyViolationFixesActionProvider} from './lib/apply-violation-fixes-action-provider'; +import {ApplyViolationFixesAction} from './lib/apply-violation-fixes-action'; // Object to hold the state of our extension for a specific activation context, to be returned by our activate function @@ -191,6 +191,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.Uri.file(f))); }); + // ================================================================================================================= // == Code Analyzer PMD Quick-Fix Functionality for Line or Class Level Suppressions // ================================================================================================================= @@ -203,6 +204,28 @@ export async function activate(context: vscode.ExtensionContext): Promise diagnosticManager.clearDiagnosticsInRange(uri, range)); + + // ================================================================================================================= + // == Unified Diff Service + // ================================================================================================================= + const unifiedDiffService: UnifiedDiffService = new UnifiedDiffServiceImpl(settingsManager, display); + unifiedDiffService.register(); + context.subscriptions.push(unifiedDiffService); + + + // ================================================================================================================= + // == Apply Violation Fixes Functionality + // ================================================================================================================= + const applyViolationFixesAction: ApplyViolationFixesAction = new ApplyViolationFixesAction( + unifiedDiffService, diagnosticManager, telemetryService, logger, display); + const applyViolationFixesActionProvider: ApplyViolationFixesActionProvider = new ApplyViolationFixesActionProvider(); + registerCommand(ApplyViolationFixesAction.COMMAND, async (diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument) => { + await applyViolationFixesAction.run(diagnostic, document); + }); + registerCodeActionsProvider({pattern: '**/**'}, applyViolationFixesActionProvider, + {providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]}); + + // ================================================================================================================= // == Apex Guru Integration Functionality // ================================================================================================================= @@ -232,47 +255,21 @@ export async function activate(context: vscode.ExtensionContext): Promise { - const edit = new vscode.WorkspaceEdit(); - edit.insert(document.uri, position, suggestedCode); - await vscode.workspace.applyEdit(edit); - telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS, { - executedCommand: Constants.QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS, - lines: suggestedCode.split('\n').length.toString() - }); - }); - - // TODO: Currently this code action provider is ApexGuru specific but soon it will be generalized: - const fixesCodeActionProvider: FixesCodeActionProvider = new FixesCodeActionProvider(); - registerCodeActionsProvider({pattern: '**/**'}, fixesCodeActionProvider, - {providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]}); - // Note the apex guru services also uses Constants.QF_COMMAND_DIAGNOSTICS_IN_RANGE (registered above) but soon this will not be the case - // ================================================================================================================= - // == Unified Diff Service - // ================================================================================================================= - const unifiedDiffService: UnifiedDiffService = new UnifiedDiffServiceImpl(settingsManager, display); - unifiedDiffService.register(); - context.subscriptions.push(unifiedDiffService); - - // ================================================================================================================= // == Agentforce for Developers Integration // ================================================================================================================= - const agentforceCodeActionProvider: AgentforceCodeActionProvider = new AgentforceCodeActionProvider(externalServiceProvider, logger); - const agentforceViolationFixer: AgentforceViolationFixer = new AgentforceViolationFixer( externalServiceProvider, codeAnalyzer, logger); - const a4dFixAction: A4DFixAction = new A4DFixAction(agentforceViolationFixer, unifiedDiffService, diagnosticManager, telemetryService, logger, display); - - registerCodeActionsProvider({language: 'apex'}, agentforceCodeActionProvider, - {providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]}); - - // Invoked by the "quick fix" buttons on A4D enabled diagnostics - registerCommand(Constants.QF_COMMAND_A4D_FIX, async (document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic) => { - await a4dFixAction.run(document, diagnostic); + const a4dFixAction: A4DFixAction = new A4DFixAction(externalServiceProvider, codeAnalyzer, unifiedDiffService, + diagnosticManager, telemetryService, logger, display); + const a4dFixActionProvider: A4DFixActionProvider = new A4DFixActionProvider(externalServiceProvider, logger); + registerCommand(A4DFixAction.COMMAND, async (document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic) => { + await a4dFixAction.run(diagnostic, document); }); + // Invoked by the "quick fix" buttons on A4D enabled diagnostics + registerCodeActionsProvider({language: 'apex'}, a4dFixActionProvider, + {providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]}); // ================================================================================================================= @@ -290,6 +287,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { await vscode.commands.executeCommand('setContext', Constants.CONTEXT_VAR_EXTENSION_ACTIVATED, false); diff --git a/src/lib/agentforce/agentforce-code-action-provider.ts b/src/lib/agentforce/a4d-fix-action-provider.ts similarity index 59% rename from src/lib/agentforce/agentforce-code-action-provider.ts rename to src/lib/agentforce/a4d-fix-action-provider.ts index a39cf5fe..e36e3b81 100644 --- a/src/lib/agentforce/agentforce-code-action-provider.ts +++ b/src/lib/agentforce/a4d-fix-action-provider.ts @@ -1,15 +1,14 @@ import * as vscode from "vscode"; import {messages} from "../messages"; -import * as Constants from "../constants"; import {LLMServiceProvider} from "../external-services/llm-service"; import {Logger} from "../logger"; import {CodeAnalyzerDiagnostic} from "../diagnostics"; -import {A4D_SUPPORTED_RULES} from "./supported-rules"; +import { A4DFixAction } from "./a4d-fix-action"; /** * Provides the A4D "Quick Fix" button on the diagnostics associated with SFCA violations for the rules we have trained the LLM on. */ -export class AgentforceCodeActionProvider implements vscode.CodeActionProvider { +export class A4DFixActionProvider implements vscode.CodeActionProvider { // This static property serves as CodeActionProviderMetadata to help aide VS Code to know when to call this provider static readonly providedCodeActionKinds: vscode.CodeActionKind[] = [vscode.CodeActionKind.QuickFix]; @@ -22,19 +21,16 @@ export class AgentforceCodeActionProvider implements vscode.CodeActionProvider { this.logger = logger; } - async provideCodeActions(document: vscode.TextDocument, range: vscode.Range, context: vscode.CodeActionContext, - _token: vscode.CancellationToken): Promise { - - const codeActions: vscode.CodeAction[] = []; + async provideCodeActions(document: vscode.TextDocument, range: vscode.Range, context: vscode.CodeActionContext): Promise { const filteredDiagnostics: CodeAnalyzerDiagnostic[] = context.diagnostics .filter(d => d instanceof CodeAnalyzerDiagnostic) - .filter(d => !d.isStale() && A4D_SUPPORTED_RULES.has(d.violation.rule)) + .filter(d => A4DFixAction.isRelevantDiagnostic(d)) // Technically, I don't think VS Code sends in diagnostics that aren't overlapping with the users selection, // but just in case they do, then this last filter is an additional sanity check just to be safe .filter(d => range.intersection(d.range) != undefined); if (filteredDiagnostics.length == 0) { - return codeActions; + return []; } // Do not provide quick fix code actions if LLM service is not available. We warn once to let user know. @@ -43,23 +39,21 @@ export class AgentforceCodeActionProvider implements vscode.CodeActionProvider { this.logger.warn(messages.agentforce.a4dQuickFixUnavailable); this.hasWarnedAboutUnavailableLLMService = true; } - return codeActions; - } - - for (const diagnostic of filteredDiagnostics) { - const fixAction: vscode.CodeAction = new vscode.CodeAction( - messages.agentforce.fixViolationWithA4D(diagnostic.violation.rule), - vscode.CodeActionKind.QuickFix - ); - fixAction.diagnostics = [diagnostic] // Important: this ties the code fix action to the specific diagnostic. - fixAction.command = { - title: 'Fix Diagnostic Issue', // Doesn't actually show up anywhere - command: Constants.QF_COMMAND_A4D_FIX, - arguments: [document, diagnostic] // The arguments passed to the run function of the AgentforceViolationFixAction - }; - codeActions.push(fixAction); + return []; } - return codeActions; + return filteredDiagnostics.map(diag => createCodeAction(diag, document)); } } + +function createCodeAction(diag: CodeAnalyzerDiagnostic, document: vscode.TextDocument): vscode.CodeAction { + const fixMsg: string = messages.fixer.applyFix(diag.violation.engine, diag.violation.rule); + const action = new vscode.CodeAction(fixMsg, vscode.CodeActionKind.QuickFix); + action.diagnostics = [diag]; // Important: this ties the code fix action to the specific diagnostic. + action.command = { + title: fixMsg, // Doesn't seem to actually show up anywhere, so just reusing the fix msg + command: A4DFixAction.COMMAND, + arguments: [diag, document] + } + return action; +} \ No newline at end of file diff --git a/src/lib/agentforce/a4d-fix-action.ts b/src/lib/agentforce/a4d-fix-action.ts index ba1f6a95..8b620c4f 100644 --- a/src/lib/agentforce/a4d-fix-action.ts +++ b/src/lib/agentforce/a4d-fix-action.ts @@ -1,130 +1,125 @@ +import * as vscode from "vscode"; +import * as Constants from "../constants"; +import {makePrompt, GUIDED_JSON_SCHEMA, LLMResponse, PromptInputs} from './llm-prompt'; import {TelemetryService} from "../external-services/telemetry-service"; import {UnifiedDiffService} from "../unified-diff-service"; -import * as Constants from "../constants"; -import * as vscode from "vscode"; import {Logger} from "../logger"; import {CodeAnalyzerDiagnostic, DiagnosticManager} from "../diagnostics"; -import {FixSuggester, FixSuggestion} from "../fix-suggestion"; -import {messages} from "../messages"; +import {FixSuggestion} from "../fix-suggestion"; +import {RangeExpander} from "../range-expander"; import {Display} from "../display"; -import {getErrorMessage, getErrorMessageWithStack} from "../utils"; -import {A4D_SUPPORTED_RULES} from "./supported-rules"; - -export class A4DFixAction { - private readonly fixSuggester: FixSuggester; - private readonly unifiedDiffService: UnifiedDiffService; - private readonly diagnosticManager: DiagnosticManager; - private readonly telemetryService: TelemetryService; - private readonly logger: Logger; - private readonly display: Display; - - constructor(fixSuggester: FixSuggester, unifiedDiffService: UnifiedDiffService, diagnosticManager: DiagnosticManager, +import {messages} from '../messages'; +import {SuggestFixWithDiffAction} from "../suggest-fix-with-diff-action"; +import {LLMService, LLMServiceProvider} from "../external-services/llm-service"; +import {CodeAnalyzer} from "../code-analyzer"; +import {A4D_SUPPORTED_RULES, ViolationContextScope} from "./supported-rules"; +import {getErrorMessage} from "../utils"; + +export class A4DFixAction extends SuggestFixWithDiffAction { + static readonly COMMAND: string = Constants.QF_COMMAND_A4D_FIX; + + static isRelevantDiagnostic(diagnostic: CodeAnalyzerDiagnostic): boolean { + return !diagnostic.isStale() && A4D_SUPPORTED_RULES.has(diagnostic.violation.rule); + } + + private readonly llmServiceProvider: LLMServiceProvider; + private readonly codeAnalyzer: CodeAnalyzer; + + constructor(llmServiceProvider: LLMServiceProvider, codeAnalyzer: CodeAnalyzer, unifiedDiffService: UnifiedDiffService, diagnosticManager: DiagnosticManager, telemetryService: TelemetryService, logger: Logger, display: Display) { - this.fixSuggester = fixSuggester; - this.unifiedDiffService = unifiedDiffService; - this.diagnosticManager = diagnosticManager; - this.telemetryService = telemetryService; - this.logger = logger; - this.display = display; + super(unifiedDiffService, diagnosticManager, telemetryService, logger, display); + this.llmServiceProvider = llmServiceProvider; + this.codeAnalyzer = codeAnalyzer; } - async run(document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic): Promise { - const startTime: number = Date.now(); - try { - if (!this.unifiedDiffService.verifyCanShowDiff(document)) { - this.telemetryService.sendCommandEvent(Constants.TELEM_QF_NO_FIX, { - commandSource: Constants.QF_COMMAND_A4D_FIX, - reason: Constants.TELEM_QF_NO_FIX_REASON_UNIFIED_DIFF_CANNOT_BE_SHOWN - }); - return; - } - - const fixSuggestion: FixSuggestion = await this.fixSuggester.suggestFix(document, diagnostic); - if (!fixSuggestion) { - this.display.displayInfo(messages.agentforce.noFixSuggested); - this.telemetryService.sendCommandEvent(Constants.TELEM_QF_NO_FIX, { - commandSource: Constants.QF_COMMAND_A4D_FIX, - languageType: document.languageId, - reason: Constants.TELEM_QF_NO_FIX_REASON_EMPTY - }); - return; - } - - const originalCode: string = fixSuggestion.getOriginalCodeToBeFixed(); - const fixedCode: string = fixSuggestion.getFixedCode(); - if (originalCode === fixedCode) { - this.display.displayInfo(messages.agentforce.noFixSuggested); - this.telemetryService.sendCommandEvent(Constants.TELEM_QF_NO_FIX, { - commandSource: Constants.QF_COMMAND_A4D_FIX, - languageType: document.languageId, - reason: Constants.TELEM_QF_NO_FIX_REASON_SAME_CODE - }); - return; - } - this.logger.debug(`Agentforce Fix Diff:\n` + - `=== ORIGINAL CODE ===:\n${originalCode}\n\n` + - `=== FIXED CODE ===:\n${fixedCode}`); - - await this.displayDiffFor(fixSuggestion); - - if (fixSuggestion.hasExplanation()) { - this.display.displayInfo(messages.agentforce.explanationOfFix(fixSuggestion.getExplanation())); - } - } catch (err) { - this.handleError(err, Constants.TELEM_A4D_SUGGESTION_FAILED, Date.now() - startTime); - return; - } + getCommandSource(): string { + return A4DFixAction.COMMAND; } - private async displayDiffFor(codeFixSuggestion: FixSuggestion): Promise { - const diagnostic: CodeAnalyzerDiagnostic = codeFixSuggestion.codeFixData.diagnostic as CodeAnalyzerDiagnostic; - const document: vscode.TextDocument = codeFixSuggestion.codeFixData.document; - const suggestedNewDocumentCode: string = codeFixSuggestion.getFixedDocumentCode(); - const numLinesInFix: number = codeFixSuggestion.getFixedCodeLines().length; - const supportedRuleName: string = A4D_SUPPORTED_RULES.has(diagnostic.violation.rule) ? diagnostic.violation.rule : ''; - - const acceptCallback: ()=>Promise = (): Promise => { - this.telemetryService.sendCommandEvent(Constants.TELEM_A4D_ACCEPT, { - commandSource: Constants.QF_COMMAND_A4D_FIX, - completionNumLines: numLinesInFix.toString(), - languageType: document.languageId, - ruleName: supportedRuleName - }); - return Promise.resolve(); - }; + getFixSuggestedTelemEventName(): string { + return Constants.TELEM_A4D_SUGGESTION; + } + + getFixAcceptedTelemEventName(): string { + return Constants.TELEM_A4D_ACCEPT; + } + + getFixRejectedTelemEventName(): string { + return Constants.TELEM_A4D_REJECT; + } - const rejectCallback: ()=>Promise = (): Promise => { - this.diagnosticManager.addDiagnostics([diagnostic]); // Put back the diagnostic - this.telemetryService.sendCommandEvent(Constants.TELEM_A4D_REJECT, { - commandSource: Constants.QF_COMMAND_A4D_FIX, - completionNumLines: numLinesInFix.toString(), - languageType: document.languageId, - ruleName: supportedRuleName - }); - return Promise.resolve(); + getFixSuggestionFailedTelemEventName(): string { + return Constants.TELEM_A4D_SUGGESTION_FAILED; + } + + /** + * Returns suggested replacement code for the entire document that should fix the violation associated with the diagnostic (using A4D). + * @param document + * @param diagnostic + */ + async suggestFix(diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument): Promise { + if (!A4DFixAction.isRelevantDiagnostic(diagnostic)) { + // This line should theoretically should not be possible to hit because this filter is already used as a + // filter in the A4DFixActionProvider, but it is here as a sanity check. + return null; + } + + const llmService: LLMService = await this.llmServiceProvider.getLLMService(); + + const engineName: string = diagnostic.violation.engine; + const ruleName: string = diagnostic.violation.rule; + + const ruleDescription: string = await this.codeAnalyzer.getRuleDescriptionFor(engineName, ruleName); + + const violationContextScope: ViolationContextScope = A4D_SUPPORTED_RULES.get(ruleName); + + const rangeExpander: RangeExpander = new RangeExpander(document); + const violationLinesRange: vscode.Range = rangeExpander.expandToCompleteLines(diagnostic.range); + let contextRange: vscode.Range = violationLinesRange; // This is the default: ViolationContextScope.ViolationScope + if (violationContextScope === ViolationContextScope.ClassScope) { + contextRange = rangeExpander.expandToClass(diagnostic.range); + } else if (violationContextScope === ViolationContextScope.MethodScope) { + contextRange = rangeExpander.expandToMethod(diagnostic.range); + } + + const promptInputs: PromptInputs = { + codeContext: document.getText(contextRange), + violatingLines: document.getText(violationLinesRange), + violationMessage: diagnostic.message, + ruleName: ruleName, + ruleDescription: ruleDescription }; + const prompt: string = makePrompt(promptInputs); - this.diagnosticManager.clearDiagnostic(diagnostic); + // Call the LLM service with the generated prompt + this.logger.trace('Sending prompt to LLM:\n' + prompt); + let llmResponseText: string; try { - await this.unifiedDiffService.showDiff(document, suggestedNewDocumentCode, acceptCallback, rejectCallback); - } catch (err) { - this.diagnosticManager.addDiagnostics([diagnostic]); // Put back the diagnostic - throw err; + llmResponseText = await llmService.callLLM(prompt, GUIDED_JSON_SCHEMA); + } catch (error) { + throw new Error(`${messages.agentforce.failedA4DResponse}\n${getErrorMessage(error)}`) } - this.telemetryService.sendCommandEvent(Constants.TELEM_A4D_SUGGESTION, { - commandSource: Constants.QF_COMMAND_A4D_FIX, - completionNumLines: numLinesInFix.toString(), - languageType: document.languageId, - ruleName: supportedRuleName - }); - } + let llmResponse: LLMResponse; + try { + llmResponse = JSON.parse(llmResponseText) as LLMResponse; + } catch (error) { + throw new Error(`Response from LLM is not valid JSON: ${getErrorMessage(error)}`); + } + + if (llmResponse.fixedCode === undefined) { + throw new Error(`Response from LLM is missing the 'fixedCode' property.`); + } + + this.logger.trace('Received response from LLM:\n' + JSON.stringify(llmResponse, undefined, 2)); - private handleError(err: unknown, errCategory: string, duration: number): void { - this.display.displayError(`${messages.agentforce.failedA4DResponse}\n${getErrorMessage(err)}`); - this.telemetryService.sendException(errCategory, getErrorMessageWithStack(err), { - executedCommand: Constants.QF_COMMAND_A4D_FIX, - duration: duration.toString() - }); + // TODO: convert the contextRange and the fixedCode into a more narrow CodeFixData that doesn't include + // leading and trailing lines that are common to the original lines. + return new FixSuggestion({ + document: document, + diagnostic: diagnostic, + rangeToBeFixed: contextRange, + fixedCode: llmResponse.fixedCode + }, llmResponse.explanation); } } diff --git a/src/lib/agentforce/agentforce-violation-fixer.ts b/src/lib/agentforce/agentforce-violation-fixer.ts deleted file mode 100644 index 0e48808c..00000000 --- a/src/lib/agentforce/agentforce-violation-fixer.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import * as vscode from 'vscode'; -import {makePrompt, GUIDED_JSON_SCHEMA, LLMResponse, PromptInputs} from './llm-prompt'; -import {LLMService, LLMServiceProvider} from "../external-services/llm-service"; -import {Logger} from "../logger"; -import {A4D_SUPPORTED_RULES, ViolationContextScope} from "./supported-rules"; -import {RangeExpander} from "../range-expander"; -import {FixSuggester, FixSuggestion} from "../fix-suggestion"; -import {getErrorMessage} from "../utils"; -import {CodeAnalyzerDiagnostic} from "../diagnostics"; -import {CodeAnalyzer} from '../code-analyzer'; - -export class AgentforceViolationFixer implements FixSuggester { - private readonly llmServiceProvider: LLMServiceProvider; - private readonly codeAnalyzer: CodeAnalyzer; - private readonly logger: Logger; - - constructor(llmServiceProvider: LLMServiceProvider, codeAnalyzer: CodeAnalyzer, logger: Logger) { - this.llmServiceProvider = llmServiceProvider; - this.codeAnalyzer = codeAnalyzer; - this.logger = logger; - } - - /** - * Returns suggested replacement code for the entire document that should fix the violation associated with the diagnostic (using A4D). - * @param document - * @param diagnostic - */ - async suggestFix(document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic): Promise { - const llmService: LLMService = await this.llmServiceProvider.getLLMService(); - - const engineName: string = diagnostic.violation.engine; - const ruleName: string = diagnostic.violation.rule; - - const ruleDescription: string = await this.codeAnalyzer.getRuleDescriptionFor(engineName, ruleName); - - const violationContextScope: ViolationContextScope | undefined = A4D_SUPPORTED_RULES.get(ruleName); - if (!violationContextScope) { - // Should never get called since suggestFix should only be called on supported rules - throw new Error(`Unsupported rule: ${ruleName}`); - } - - const rangeExpander: RangeExpander = new RangeExpander(document); - const violationLinesRange: vscode.Range = rangeExpander.expandToCompleteLines(diagnostic.range); - let contextRange: vscode.Range = violationLinesRange; // This is the default: ViolationContextScope.ViolationScope - if (violationContextScope === ViolationContextScope.ClassScope) { - contextRange = rangeExpander.expandToClass(diagnostic.range); - } else if (violationContextScope === ViolationContextScope.MethodScope) { - contextRange = rangeExpander.expandToMethod(diagnostic.range); - } - - const promptInputs: PromptInputs = { - codeContext: document.getText(contextRange), - violatingLines: document.getText(violationLinesRange), - violationMessage: diagnostic.message, - ruleName: ruleName, - ruleDescription: ruleDescription - }; - const prompt: string = makePrompt(promptInputs); - - // Call the LLM service with the generated prompt - this.logger.trace('Sending prompt to LLM:\n' + prompt); - const llmResponseText: string = await llmService.callLLM(prompt, GUIDED_JSON_SCHEMA); - let llmResponse: LLMResponse; - try { - llmResponse = JSON.parse(llmResponseText) as LLMResponse; - } catch (error) { - throw new Error(`Response from LLM is not valid JSON: ${getErrorMessage(error)}`); - } - - if (llmResponse.fixedCode === undefined) { - throw new Error(`Response from LLM is missing the 'fixedCode' property.`); - } - - this.logger.trace('Received response from LLM:\n' + JSON.stringify(llmResponse, undefined, 2)); - - // TODO: convert the contextRange and the fixedCode into a more narrow CodeFixData that doesn't include - // leading and trailing lines that are common to the original lines. - return new FixSuggestion({ - document: document, - diagnostic: diagnostic, - rangeToBeFixed: contextRange, - fixedCode: llmResponse.fixedCode - }, llmResponse.explanation); - } -} diff --git a/src/lib/apexguru/apex-guru-service.ts b/src/lib/apexguru/apex-guru-service.ts index ba548c43..63b50ad8 100644 --- a/src/lib/apexguru/apex-guru-service.ts +++ b/src/lib/apexguru/apex-guru-service.ts @@ -10,9 +10,10 @@ import * as fspromises from 'fs/promises'; import {Connection, CoreExtensionService} from '../core-extension-service'; import * as Constants from '../constants'; import {messages} from '../messages'; -import {CodeAnalyzerDiagnostic, DiagnosticManager, Violation} from '../diagnostics'; +import {CodeAnalyzerDiagnostic, CodeLocation, DiagnosticManager, toRange, Violation} from '../diagnostics'; import {TelemetryService} from "../external-services/telemetry-service"; import {Logger} from "../logger"; +import { indent } from '../utils'; export async function isApexGuruEnabledInOrg(logger: Logger): Promise { try { @@ -129,10 +130,12 @@ export function transformReportJsonStringToDiagnostics(fileName: string, jsonStr } function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerDiagnostic { - const encodedCodeBefore = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_before')?.value - ?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_before')?.value - ?? ''; - const currentCode: string = Buffer.from(encodedCodeBefore, 'base64').toString('utf8'); + // TODO: We have no need for "currentCode" right now. Temporarily leaving this code here until we get the new payload updates. + // const encodedCodeBefore = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_before')?.value + // ?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_before')?.value + // ?? ''; + // const currentCode: string = Buffer.from(encodedCodeBefore, 'base64').toString('utf8'); + const encodedCodeAfter = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_after')?.value ?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_after')?.value ?? ''; @@ -140,16 +143,18 @@ function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerD const lineNumber = parseInt(parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'line_number')?.value); + const violationLocation: CodeLocation = { + file: file, + startLine: lineNumber, + startColumn: 1 + }; + const violation: Violation = { rule: parsed.type, engine: 'apexguru', message: parsed.value, severity: 1, // TODO: Should this really be critical level violation? This seems off. - locations: [{ - file: file, - startLine: lineNumber, - startColumn: 1 - }], + locations: [violationLocation], primaryLocationIndex: 0, tags: [], resources: [ @@ -157,19 +162,39 @@ function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerD ] }; - const diagnostic: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(violation); + // TODO: Soon we'll be receiving a different looking payload which will help us differentiate between fixes and suggestions. + // For now, we are going to treat suggestedCode as a fix and a suggestion (as the current pilot code does) if (suggestedCode.length > 0) { + violation.fixes = [ + { + location: violationLocation, + fixedCode: `/*\n//ApexGuru Suggestions: \n${suggestedCode}\n*/` + } + ] + violation.suggestions = [ + { + location: violationLocation, + // This message is temporary and will be improved as we get a better response back and unify the suggestions experience + message: `ApexGuru Suggestion:\n${indent(suggestedCode)}\n` + } + ] + } + + const diagnostic: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(violation); + + + // TODO: This is temporary until we address the unification of suggestions (which will have a better way of showing suggestions on the vscode editor window) + if (violation.suggestions?.length > 0) { diagnostic.relatedInformation = [ new vscode.DiagnosticRelatedInformation( - new vscode.Location(vscode.Uri.parse(violation.resources[0]), diagnostic.range), - `\n// Current Code: \n${currentCode}` - ), - new vscode.DiagnosticRelatedInformation( - new vscode.Location(vscode.Uri.parse(violation.resources[0]), diagnostic.range), - `/*\n//ApexGuru Suggestions: \n${suggestedCode}\n*/` + new vscode.Location( + vscode.Uri.parse(violation.suggestions[0].location.file), // When we have a better way of displaying these, we'll need a loop instead of assuming just 1 suggestion + toRange(violation.suggestions[0].location)), + violation.suggestions[0].message ) ]; } + return diagnostic; } diff --git a/src/lib/apply-violation-fixes-action-provider.ts b/src/lib/apply-violation-fixes-action-provider.ts new file mode 100644 index 00000000..424c2cf4 --- /dev/null +++ b/src/lib/apply-violation-fixes-action-provider.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import * as vscode from 'vscode'; +import {messages} from './messages'; +import {CodeAnalyzerDiagnostic} from "./diagnostics"; +import { ApplyViolationFixesAction } from './apply-violation-fixes-action'; + +/** + * Class for providing quick fix functionality to diagnostics associated with Code Analyzer violations that contain fixes + */ +export class ApplyViolationFixesActionProvider implements vscode.CodeActionProvider { + public provideCodeActions(document: vscode.TextDocument, selectedRange: vscode.Range, context: vscode.CodeActionContext): vscode.CodeAction[] { + const filteredDiagnostics: CodeAnalyzerDiagnostic[] = context.diagnostics + .filter(d => d instanceof CodeAnalyzerDiagnostic) + .filter(d => ApplyViolationFixesAction.isRelevantDiagnostic(d, document)) + + // Technically, I don't think VS Code sends in diagnostics that aren't overlapping with the users selection, + // but just in case they do, then this last filter is an additional sanity check just to be safe + .filter(d => selectedRange.intersection(d.range) != undefined) + + if (filteredDiagnostics.length == 0) { + return []; + } + + return filteredDiagnostics.map(diag => createCodeAction(diag, document)); + } +} + +function createCodeAction(diag: CodeAnalyzerDiagnostic, document: vscode.TextDocument): vscode.CodeAction { + const fixMsg: string = messages.fixer.applyFix(diag.violation.engine, diag.violation.rule); + const action = new vscode.CodeAction(fixMsg, vscode.CodeActionKind.QuickFix); + action.diagnostics = [diag]; // Important: this ties the code fix action to the specific diagnostic. + action.command = { + title: fixMsg, // Doesn't seem to actually show up anywhere, so just reusing the fix msg + command: ApplyViolationFixesAction.COMMAND, + arguments: [diag, document] + } + return action; +} \ No newline at end of file diff --git a/src/lib/apply-violation-fixes-action.ts b/src/lib/apply-violation-fixes-action.ts new file mode 100644 index 00000000..51d1c71e --- /dev/null +++ b/src/lib/apply-violation-fixes-action.ts @@ -0,0 +1,70 @@ +import * as vscode from "vscode"; +import * as Constants from "./constants" +import {TelemetryService} from "./external-services/telemetry-service"; +import {UnifiedDiffService} from "./unified-diff-service"; +import {Logger} from "./logger"; +import {CodeAnalyzerDiagnostic, DiagnosticManager, Fix, toRange} from "./diagnostics"; +import {CodeFixData, FixSuggestion} from "./fix-suggestion"; +import {Display} from "./display"; +import { SuggestFixWithDiffAction } from "./suggest-fix-with-diff-action"; + +export class ApplyViolationFixesAction extends SuggestFixWithDiffAction { + static readonly COMMAND: string = Constants.QF_COMMAND_APPLY_VIOLATION_FIXES; + + static isRelevantDiagnostic(diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument): boolean { + return !diagnostic.isStale() && + diagnostic.violation.fixes?.length > 0 && + // Currently we only mark relevant the diagnostics with all its fixes corresponding to the document + diagnostic.violation.fixes.every(f => f.location.file === document.fileName); + } + + constructor(unifiedDiffService: UnifiedDiffService, diagnosticManager: DiagnosticManager, + telemetryService: TelemetryService, logger: Logger, display: Display) { + super(unifiedDiffService, diagnosticManager, telemetryService, logger, display) + } + + getCommandSource(): string { + return ApplyViolationFixesAction.COMMAND; + } + + getFixSuggestedTelemEventName(): string { + return Constants.TELEM_QF_FIX_SUGGESTED; + } + + getFixSuggestionFailedTelemEventName(): string { + return Constants.TELEM_QF_FIX_SUGGESTION_FAILED + } + + getFixAcceptedTelemEventName(): string { + return Constants.TELEM_QF_FIX_ACCEPTED; + } + + getFixRejectedTelemEventName(): string { + return Constants.TELEM_QF_FIX_REJECTED; + } + + suggestFix(diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument): Promise { + if (!ApplyViolationFixesAction.isRelevantDiagnostic(diagnostic, document)) { + // This line should theoretically should not be possible to hit because this filter is already provided in + // the ApplyViolationFixesActionProvider, but it is here as a sanity check + return Promise.resolve(null) + } + + const consolidatedFix: Fix = diagnostic.violation.fixes.length > 1 ? + consolidateFixes(diagnostic.violation.fixes, document) : diagnostic.violation.fixes[0]; + + const codeFixData: CodeFixData = { + document: document, + diagnostic: diagnostic, + rangeToBeFixed: toRange(consolidatedFix.location), + fixedCode: consolidatedFix.fixedCode + } + return Promise.resolve(new FixSuggestion(codeFixData)); + } +} + + +function consolidateFixes(_fixes: Fix[], _document: vscode.TextDocument): Fix { + // TODO: W-19264999 (Not needed until either ApexGuru returns multiple Fixes per violation or we add in engines that do) + throw new Error('Support for consolidating multiple fixes into a single fix has not been implemented yet.'); +} \ No newline at end of file diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cd2fea0e..700d72a0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -23,8 +23,8 @@ export const COMMAND_RUN_APEX_GURU_ON_ACTIVE_FILE = 'sfca.runApexGuruAnalysisOnC // commands that are only invoked by quick fixes (which do not need to be declared in package.json since they can be registered dynamically) export const QF_COMMAND_DIAGNOSTICS_IN_RANGE = 'sfca.removeDiagnosticsInRange'; -export const QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS = 'sfca.includeApexGuruSuggestions'; export const QF_COMMAND_A4D_FIX = 'sfca.a4dFix'; +export const QF_COMMAND_APPLY_VIOLATION_FIXES = 'sfca.applyViolationFixes'; // other commands that we use export const VSCODE_COMMAND_OPEN_URL = 'vscode.open'; @@ -37,19 +37,18 @@ export const TELEM_SUCCESSFUL_DFA_ANALYSIS = 'sfdx__codeanalyzer_dfa_run_complet export const TELEM_FAILED_DFA_ANALYSIS = 'sfdx__codeanalyzer_dfa_run_failed'; export const TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS = 'sfdx__apexguru_file_run_complete'; -// telemetry keys used by eGPT +// telemetry keys used by eGPT (A4D) export const TELEM_A4D_SUGGESTION = 'sfdx__eGPT_suggest'; export const TELEM_A4D_SUGGESTION_FAILED = 'sfdx__eGPT_suggest_failure'; export const TELEM_A4D_ACCEPT = 'sfdx__eGPT_accept'; export const TELEM_A4D_REJECT = 'sfdx__eGPT_clear'; -// quick fix telemetry events -export const TELEM_QF_NO_FIX = 'sfdx__codeanalyzer_qf_no_fix_suggested'; - -// quick fix telemetry event properties -export const TELEM_QF_NO_FIX_REASON_UNIFIED_DIFF_CANNOT_BE_SHOWN = 'unified_diff_cannot_be_shown'; -export const TELEM_QF_NO_FIX_REASON_EMPTY = 'empty'; -export const TELEM_QF_NO_FIX_REASON_SAME_CODE = 'same_code'; +// telemetry event keys for the general ApplyViolationFixesAction +export const TELEM_QF_NO_FIX_SUGGESTED = 'sfdx__codeanalyzer_qf_no_fix_suggested'; +export const TELEM_QF_FIX_SUGGESTED = 'sfdx__codeanalyzer_qf_fix_suggested'; +export const TELEM_QF_FIX_SUGGESTION_FAILED = 'sfdx__codeanalyzer_qf_fix_suggestion_failed'; +export const TELEM_QF_FIX_ACCEPTED = 'sfdx__codeanalyzer_qf_fix_accepted'; +export const TELEM_QF_FIX_REJECTED = 'sfdx__codeanalyzer_qf_fix_rejected'; // versioning export const MINIMUM_REQUIRED_VERSION_CORE_EXTENSION = '58.4.1'; diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 514312e0..3cc9119d 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -7,6 +7,26 @@ import {messages} from './messages'; import * as vscode from 'vscode'; +// For now we attempt to match the JsonViolationOutput schema as much as possible so that we don't need to transform +// the results that we read from the output json files too much. When we move away from using the CLI, then we can +// instead use the results data structures from core. But for now, see: +// - https://github.com/forcedotcom/code-analyzer-core/blob/dev/packages/code-analyzer-core/src/output-formats/results/json-run-results-format.ts +export type Violation = { + rule: string; + engine: string; + message: string; + severity: number; + locations: CodeLocation[]; + primaryLocationIndex: number; + tags: string[]; + resources: string[]; + + // NOTE: The following fields currently do not exist our json schema, and only lives here for apex guru. Eventually + // these fields might get added to Code Analyzer core for engines like "eslint" and our output schemas. + fixes?: Fix[]; + suggestions?: Suggestion[]; +} + export type CodeLocation = { // These all should be optional just like it is over at: // - https://github.com/forcedotcom/code-analyzer-core/blob/dev/packages/code-analyzer-core/src/results.ts#L14 @@ -19,15 +39,20 @@ export type CodeLocation = { comment?: string; } -export type Violation = { - rule: string; - engine: string; +export type Fix = { + // The location associated with the block of original code that will be replaced by the fixed code + location: CodeLocation; + + // The new code that will replace the block of original code + fixedCode: string; +} + +export type Suggestion = { + // The location associated with the block of code that the suggestion is associated with + location: CodeLocation; + + // The suggestion message message: string; - severity: number; - locations: CodeLocation[]; - primaryLocationIndex: number; - tags: string[]; - resources: string[]; } const STALE_PREFIX: string = messages.staleDiagnosticPrefix + '\n'; @@ -39,11 +64,12 @@ export class CodeAnalyzerDiagnostic extends vscode.Diagnostic { readonly violation: Violation; readonly uri: vscode.Uri; + // Private - see the fromViolation method below to see assumptions made on this constructor private constructor(violation: Violation) { const primaryLocation: CodeLocation = violation.locations[violation.primaryLocationIndex]; super(toRange(primaryLocation), messages.diagnostics.messageGenerator(violation.severity, violation.message.trim()), - vscode.DiagnosticSeverity.Warning); // TODO: For V5, we should consider using Error for sev 1 and Information for sev 5 instead of always just using Warning. + vscode.DiagnosticSeverity.Warning); // TODO: We should consider using 'Error' for sev 1 instead of always just using 'Warning'. Note that we reserve 'Information' for stale diagnostics. this.violation = violation; this.uri = vscode.Uri.file(primaryLocation.file); } @@ -86,7 +112,7 @@ export class CodeAnalyzerDiagnostic extends vscode.Diagnostic { diagnostic.range.start.line, Number.MAX_SAFE_INTEGER); } - diagnostic.source = messages.diagnostics.source.generator(violation.engine); + diagnostic.source = `${violation.engine} ${messages.diagnostics.source.suffix}`; diagnostic.code = violation.resources.length > 0 ? { target: vscode.Uri.parse(violation.resources[0]), value: violation.rule @@ -96,11 +122,11 @@ export class CodeAnalyzerDiagnostic extends vscode.Diagnostic { if (violation.locations.length > 1) { const relatedLocations: vscode.DiagnosticRelatedInformation[] = []; for (let i = 0 ; i < violation.locations.length; i++) { - if (i !== violation.primaryLocationIndex) { - const relatedLocation = violation.locations[i]; + const relatedLocation: CodeLocation = violation.locations[i]; + if (i !== violation.primaryLocationIndex && relatedLocation.file) { const relatedRange = toRange(relatedLocation); const vscodeLocation: vscode.Location = new vscode.Location(vscode.Uri.file(relatedLocation.file), relatedRange); - relatedLocations.push(new vscode.DiagnosticRelatedInformation(vscodeLocation, relatedLocation.comment)); + relatedLocations.push(new vscode.DiagnosticRelatedInformation(vscodeLocation, relatedLocation.comment ?? '')); } } diagnostic.relatedInformation = relatedLocations; @@ -195,7 +221,7 @@ export class DiagnosticManagerImpl implements DiagnosticManager { } -function toRange(codeLocation: CodeLocation): vscode.Range { +export function toRange(codeLocation: CodeLocation): vscode.Range { // If there's no explicit startLine, just use the first line. const startLine: number = codeLocation.startLine != null ? adjustToZeroBased(codeLocation.startLine) : 0; // If there's no explicit startColumn, just use the first column. diff --git a/src/lib/fix-suggestion.ts b/src/lib/fix-suggestion.ts index 844972cf..ce7a0141 100644 --- a/src/lib/fix-suggestion.ts +++ b/src/lib/fix-suggestion.ts @@ -1,9 +1,4 @@ import * as vscode from "vscode"; -import {CodeAnalyzerDiagnostic} from "./diagnostics"; - -export interface FixSuggester { - suggestFix(document: vscode.TextDocument, diagnostic: CodeAnalyzerDiagnostic): Promise -} export type CodeFixData = { // The document associated with the fix diff --git a/src/lib/fixes-code-action-provider.ts b/src/lib/fixes-code-action-provider.ts deleted file mode 100644 index 0e5e76ce..00000000 --- a/src/lib/fixes-code-action-provider.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as vscode from 'vscode'; -import {messages} from './messages'; -import * as Constants from './constants'; -import {CodeAnalyzerDiagnostic} from "./diagnostics"; - -/** - * Class for providing quick fix functionality to diagnostics associated with Code Analyzer violations that contain fixes - * - * NOTE: Currently this is hard coded to only work on ApexGuru based violations - but soon will be generalized to work on all violations - */ -export class FixesCodeActionProvider implements vscode.CodeActionProvider { - public provideCodeActions(document: vscode.TextDocument, selectedRange: vscode.Range, context: vscode.CodeActionContext): vscode.CodeAction[] { - const filteredDiagnostics: CodeAnalyzerDiagnostic[] = context.diagnostics - .filter(d => d instanceof CodeAnalyzerDiagnostic) - .filter(d => !d.isStale()) - - // THIS IS TEMPORARY - WE'LL SWITCH THIS FILTER TO INSPECT THE VIOLATION SOON INSTEAD OF LOOKING FOR apexguru diagnostics that have relatedInformation - .filter(d => d.violation.engine === 'apexguru') - .filter(d => d.relatedInformation && d.relatedInformation.length > 0) - - // Technically, I don't think VS Code sends in diagnostics that aren't overlapping with the users selection, - // but just in case they do, then this last filter is an additional sanity check just to be safe - .filter(d => selectedRange.intersection(d.range) != undefined) - if (filteredDiagnostics.length == 0) { - return []; - } - - return filteredDiagnostics.map(diag => createCodeAction(document, diag)).flat(); - } -} - -function createCodeAction(document: vscode.TextDocument, diag: CodeAnalyzerDiagnostic): vscode.CodeAction { - const suggestedCode = diag.relatedInformation[1].message; // <-- !! THIS IS A BAD ASSUMPTION - WILL FIX THIS SOON !! - - const action = new vscode.CodeAction( - messages.fixer.fixWithApexGuruSuggestions, // TODO: This will go away soon in favor of a generalized message - vscode.CodeActionKind.QuickFix); - action.diagnostics = [diag]; - const range = diag.range; // Assuming the range is the location of the existing code in the document // <--- !! THIS IS A BAD ASSUMPTION - WILL FIX SOON !! - const diagnosticStartLine = new vscode.Position(range.start.line, range.start.character); - - // TODO: WILL REWORK THIS SOON IN FAVOR OF A MORE GENERALIZED APPROACH - action.command = { - title: 'Apply ApexGuru Fix', - command: Constants.QF_COMMAND_INCLUDE_APEX_GURU_SUGGESTIONS, - arguments: [document, diagnosticStartLine, suggestedCode + '\n'] - } - - return action; -} \ No newline at end of file diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 7c26569a..920e925b 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -16,10 +16,7 @@ export const messages = { }, agentforce: { a4dQuickFixUnavailable: "The ability to fix violations with 'Agentforce for Developers' is unavailable since a compatible 'Agentforce for Developers' extension was not found or activated. To enable this functionality, please install the 'Agentforce for Developers' extension and restart VS Code.", - fixViolationWithA4D: (ruleName: string) => `Fix '${ruleName}' using Agentforce for Developers.`, - failedA4DResponse: "Unable to receive code fix suggestion from Agentforce for Developers.", - explanationOfFix: (explanation: string) => `Fix Explanation: ${explanation}`, - noFixSuggested: "No fix was suggested." + failedA4DResponse: "Unable to receive code fix suggestion from Agentforce for Developers." }, unifiedDiff: { mustAcceptOrRejectDiffFirst: "You must accept or reject all changes before performing this action.", @@ -46,15 +43,16 @@ export const messages = { existingDfaRunText: "A Salesforce Graph Engine analysis is already running. Cancel it by clicking in the Status Bar.", }, fixer: { - suppressPMDViolationsOnLine: "Suppress all PMD violations on this line.", - suppressPmdViolationsOnClass: (ruleName?: string) => ruleName ? `Suppress '${ruleName}' on this class.` : `Suppress all PMD violations on this class.`, - fixWithApexGuruSuggestions: "Insert ApexGuru suggestions." // TODO: This will go away soon in favor of a generalized message + suppressPMDViolationsOnLine: "Suppress all 'pmd' violations on this line", + suppressPmdViolationsOnClass: (ruleName: string) => `Suppress 'pmd.${ruleName}' on this class`, + applyFix: (engineName: string, ruleName: string) => `Fix '${engineName}.${ruleName}' using Code Analyzer`, + noFixSuggested: "No fix was suggested.", + explanationOfFix: (explanation: string) => `Fix Explanation: ${explanation}` }, diagnostics: { messageGenerator: (severity: number, message: string) => `Sev${severity}: ${message}`, source: { - suffix: 'via Code Analyzer', - generator: (engine: string) => `${engine} ${messages.diagnostics.source.suffix}` + suffix: 'via Code Analyzer' } }, targeting: { diff --git a/src/lib/pmd/pmd-suppressions-code-action-provider.ts b/src/lib/pmd/pmd-suppressions-code-action-provider.ts index 99861b71..ff06f66b 100644 --- a/src/lib/pmd/pmd-suppressions-code-action-provider.ts +++ b/src/lib/pmd/pmd-suppressions-code-action-provider.ts @@ -76,7 +76,7 @@ function generateClassLevelSuppression(document: vscode.TextDocument, diag: Code const ruleName: string = diag.violation.rule; const suppressionTag: string = `PMD.${ruleName}`; - const suppressMsg: string = messages.fixer.suppressPmdViolationsOnClass(suppressionTag); + const suppressMsg: string = messages.fixer.suppressPmdViolationsOnClass(ruleName); const action = new vscode.CodeAction(suppressMsg, vscode.CodeActionKind.QuickFix); action.edit = new vscode.WorkspaceEdit(); diff --git a/src/lib/suggest-fix-with-diff-action.ts b/src/lib/suggest-fix-with-diff-action.ts new file mode 100644 index 00000000..c345cb27 --- /dev/null +++ b/src/lib/suggest-fix-with-diff-action.ts @@ -0,0 +1,146 @@ +import * as vscode from "vscode"; +import * as Constants from "./constants" +import {TelemetryService} from "./external-services/telemetry-service"; +import {UnifiedDiffService} from "./unified-diff-service"; +import {Logger} from "./logger"; +import {CodeAnalyzerDiagnostic, DiagnosticManager} from "./diagnostics"; +import {FixSuggestion} from "./fix-suggestion"; +import {messages} from "./messages"; +import {Display} from "./display"; +import {getErrorMessage, getErrorMessageWithStack} from "./utils"; + +const NO_FIX_REASON = { + UNIFIED_DIFF_CANNOT_BE_SHOWN: 'unified_diff_cannot_be_shown', + EMPTY: 'empty', + SAME_CODE: 'same_code' +} + +/** + * Abstract class to help share the unified diff functionality and accept/reject telemetry with various quick fix commands + */ +export abstract class SuggestFixWithDiffAction { + private readonly unifiedDiffService: UnifiedDiffService; + private readonly diagnosticManager: DiagnosticManager; + private readonly telemetryService: TelemetryService; + protected readonly logger: Logger; + private readonly display: Display; + + protected abstract suggestFix(diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument): Promise + + protected abstract getCommandSource(): string + protected abstract getFixSuggestedTelemEventName(): string + protected abstract getFixSuggestionFailedTelemEventName(): string + protected abstract getFixAcceptedTelemEventName(): string + protected abstract getFixRejectedTelemEventName(): string + + constructor(unifiedDiffService: UnifiedDiffService, diagnosticManager: DiagnosticManager, + telemetryService: TelemetryService, logger: Logger, display: Display) { + this.unifiedDiffService = unifiedDiffService; + this.diagnosticManager = diagnosticManager; + this.telemetryService = telemetryService; + this.logger = logger; + this.display = display; + } + + async run(diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument): Promise { + const startTime: number = Date.now(); + try { + if (!this.unifiedDiffService.verifyCanShowDiff(document)) { + this.telemetryService.sendCommandEvent(Constants.TELEM_QF_NO_FIX_SUGGESTED, { + commandSource: this.getCommandSource(), + reason: NO_FIX_REASON.UNIFIED_DIFF_CANNOT_BE_SHOWN + }); + return; + } + + const fixSuggestion: FixSuggestion = await this.suggestFix(diagnostic, document); + if (!fixSuggestion) { + this.display.displayInfo(messages.fixer.noFixSuggested); + this.telemetryService.sendCommandEvent(Constants.TELEM_QF_NO_FIX_SUGGESTED, { + commandSource: this.getCommandSource(), + languageType: document.languageId, + reason: NO_FIX_REASON.EMPTY + }); + return; + } + + const originalCode: string = fixSuggestion.getOriginalCodeToBeFixed(); + const fixedCode: string = fixSuggestion.getFixedCode(); + if (originalCode === fixedCode) { + this.display.displayInfo(messages.fixer.noFixSuggested); + this.telemetryService.sendCommandEvent(Constants.TELEM_QF_NO_FIX_SUGGESTED, { + commandSource: this.getCommandSource(), + languageType: document.languageId, + reason: NO_FIX_REASON.SAME_CODE + }); + return; + } + this.logger.debug(`Fix Diff:\n` + + `=== ORIGINAL CODE ===:\n${originalCode}\n\n` + + `=== FIXED CODE ===:\n${fixedCode}`); + + await this.displayDiffFor(fixSuggestion); + + if (fixSuggestion.hasExplanation()) { + this.display.displayInfo(messages.fixer.explanationOfFix(fixSuggestion.getExplanation())); + } + } catch (err) { + this.handleError(err, this.getFixSuggestionFailedTelemEventName(), Date.now() - startTime); + return; + } + } + + private async displayDiffFor(codeFixSuggestion: FixSuggestion): Promise { + const diagnostic: CodeAnalyzerDiagnostic = codeFixSuggestion.codeFixData.diagnostic as CodeAnalyzerDiagnostic; + const document: vscode.TextDocument = codeFixSuggestion.codeFixData.document; + const suggestedNewDocumentCode: string = codeFixSuggestion.getFixedDocumentCode(); + const numLinesInFix: number = codeFixSuggestion.getFixedCodeLines().length; + + const acceptCallback: ()=>Promise = (): Promise => { + this.telemetryService.sendCommandEvent(this.getFixAcceptedTelemEventName(), { + commandSource: this.getCommandSource(), + completionNumLines: numLinesInFix.toString(), + languageType: document.languageId, + engineName: diagnostic.violation.engine, + ruleName: diagnostic.violation.rule + }); + return Promise.resolve(); + }; + + const rejectCallback: ()=>Promise = (): Promise => { + this.diagnosticManager.addDiagnostics([diagnostic]); // Put back the diagnostic + this.telemetryService.sendCommandEvent(this.getFixRejectedTelemEventName(), { + commandSource: this.getCommandSource(), + completionNumLines: numLinesInFix.toString(), + languageType: document.languageId, + engineName: diagnostic.violation.engine, + ruleName: diagnostic.violation.rule + }); + return Promise.resolve(); + }; + + this.diagnosticManager.clearDiagnostic(diagnostic); + try { + await this.unifiedDiffService.showDiff(document, suggestedNewDocumentCode, acceptCallback, rejectCallback); + } catch (err) { + this.diagnosticManager.addDiagnostics([diagnostic]); // Put back the diagnostic + throw err; + } + + this.telemetryService.sendCommandEvent(this.getFixSuggestedTelemEventName(), { + commandSource: this.getCommandSource(), + completionNumLines: numLinesInFix.toString(), + languageType: document.languageId, + engineName: diagnostic.violation.engine, + ruleName: diagnostic.violation.rule + }); + } + + private handleError(err: unknown, errCategory: string, duration: number): void { + this.display.displayError(getErrorMessage(err)); + this.telemetryService.sendException(errCategory, getErrorMessageWithStack(err), { + executedCommand: this.getCommandSource(), + duration: duration.toString() + }); + } +} \ No newline at end of file diff --git a/src/test/legacy/apexguru/apex-guru-service.test.ts b/src/test/legacy/apexguru/apex-guru-service.test.ts index 51fe36fd..13331e72 100644 --- a/src/test/legacy/apexguru/apex-guru-service.test.ts +++ b/src/test/legacy/apexguru/apex-guru-service.test.ts @@ -164,60 +164,37 @@ suite('Apex Guru Test Suite', () => { const diagnostics: CodeAnalyzerDiagnostic[] = ApexGuruFunctions.transformReportJsonStringToDiagnostics(fileName, jsonString); expect(diagnostics).to.have.length(1); + const expectedSuggestedCode: string = 'System.out.println("New Hello World");'; expect(diagnostics[0].violation).to.deep.equal({ rule: 'BestPractices', engine: 'apexguru', message: 'Avoid using System.debug', severity: 1, - tags: [], locations: [{ file: fileName, startLine: 10, startColumn: 1 }], primaryLocationIndex: 0, - resources: ['https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5'] - }); - const expectedCurrentCode: string = 'System.out.println("Old Hello World");'; - const expectedSuggestedCode: string = 'System.out.println("New Hello World");'; - expect(diagnostics[0].relatedInformation).to.have.length(2); - expect(diagnostics[0].relatedInformation[0].message).to.equal(`\n// Current Code: \n${expectedCurrentCode}`); - expect(diagnostics[0].relatedInformation[1].message).to.equal(`/*\n//ApexGuru Suggestions: \n${expectedSuggestedCode}\n*/`); - }); - - test('Transforms valid JSON string to Violations for code violations', () => { - const fileName = 'TestFile.cls'; - const jsonString = JSON.stringify([{ - type: 'BestPractices', - value: 'Avoid using System.debug', - properties: [ - { name: 'line_number', value: '10' }, - { name: 'class_after', value: Buffer.from('System.out.println("New Hello World");').toString('base64') }, - { name: 'class_before', value: Buffer.from('System.out.println("Old Hello World");').toString('base64') } - ] - }]); - - const diagnostics: CodeAnalyzerDiagnostic[] = ApexGuruFunctions.transformReportJsonStringToDiagnostics(fileName, jsonString); - expect(diagnostics).to.have.length(1); - expect(diagnostics[0].violation).to.deep.equal({ - rule: 'BestPractices', - engine: 'apexguru', - message: 'Avoid using System.debug', - severity: 1, tags: [], - locations: [{ + resources: ['https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5'], + fixes: [{ + location: { file: fileName, startLine: 10, startColumn: 1 + }, + fixedCode: `/*\n//ApexGuru Suggestions: \n${expectedSuggestedCode}\n*/` }], - primaryLocationIndex: 0, - resources: ['https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5'] + suggestions: [{ + location: { + file: fileName, + startLine: 10, + startColumn: 1 + }, + message: `ApexGuru Suggestion:\n ${expectedSuggestedCode}\n` + }] }); - const expectedCurrentCode: string = 'System.out.println("Old Hello World");'; - const expectedSuggestedCode: string = 'System.out.println("New Hello World");'; - expect(diagnostics[0].relatedInformation).to.have.length(2); - expect(diagnostics[0].relatedInformation[0].message).to.equal(`\n// Current Code: \n${expectedCurrentCode}`); - expect(diagnostics[0].relatedInformation[1].message).to.equal(`/*\n//ApexGuru Suggestions: \n${expectedSuggestedCode}\n*/`); }); test('Transforms valid JSON string to Violations for violations with no suggestions', () => { diff --git a/src/test/unit/lib/agentforce/a4d-fix-action.test.ts b/src/test/unit/lib/agentforce/a4d-fix-action.test.ts index 7c6bc284..29840a8c 100644 --- a/src/test/unit/lib/agentforce/a4d-fix-action.test.ts +++ b/src/test/unit/lib/agentforce/a4d-fix-action.test.ts @@ -1,4 +1,4 @@ -import * as vscode from "vscode";// The vscode module is mocked out. See: scripts/setup.jest.ts +import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts import {CodeAnalyzerDiagnostic, DiagnosticManager, DiagnosticManagerImpl} from "../../../../lib/diagnostics"; import * as stubs from "../../stubs"; @@ -7,21 +7,24 @@ import {A4DFixAction} from "../../../../lib/agentforce/a4d-fix-action"; import {createTextDocument} from "jest-mock-vscode"; import {createSampleCodeAnalyzerDiagnostic} from "../../test-utils"; import {messages} from "../../../../lib/messages"; -import {FixSuggestion} from "../../../../lib/fix-suggestion"; +import { LLMServiceProvider } from "../../../../lib/external-services/llm-service"; +import { CodeAnalyzer } from "../../../../lib/code-analyzer"; describe('Tests for A4DFixAction', () => { const sampleUri: vscode.Uri = vscode.Uri.file('/some/file.cls'); - const sampleDocument: vscode.TextDocument = createTextDocument(sampleUri, 'some\nsample content', 'apex'); - const sampleDiagnostic1: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleUri, new vscode.Range(0,0,0,1), 'ApexDoc'); - const sampleDiagnostic2: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleUri, new vscode.Range(1,7,1,14)); - const sampleFixSuggestion: FixSuggestion = new FixSuggestion({ - document: sampleDocument, - diagnostic: sampleDiagnostic1, - rangeToBeFixed: new vscode.Range(0, 1, 0, 4), - fixedCode: 'someFixedCode' - }); - - let fixSuggester: stubs.SpyFixSuggester; + const sampleContent: string = + 'This is some dummy content\n' + + 'that is multi-line\n' + + ' with spaces and such\n' + + ' within the content.'; + const sampleDocument: vscode.TextDocument = createTextDocument(sampleUri, sampleContent, 'apex'); + // These diagnostics are associated with rules that only care about the ViolationScope (where the range expander expands to full lines) + const sampleDiagForSingleLine: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleUri, new vscode.Range(0,0,0,1), 'ApexAssertionsShouldIncludeMessage'); + const sampleDiagThatSpansTwoLines: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleUri, new vscode.Range(1,7,2,14), 'UnusedLocalVariable'); + + let spyLLMService: stubs.SpyLLMService; + let llmServiceProvider: LLMServiceProvider; + let codeAnalyzer: stubs.StubCodeAnalyzer; let unifiedDiffService: stubs.SpyUnifiedDiffService; let diagnosticCollection: vscode.DiagnosticCollection; let diagnosticManager: DiagnosticManager; @@ -31,21 +34,23 @@ describe('Tests for A4DFixAction', () => { let a4dFixAction: A4DFixAction; beforeEach(() => { + spyLLMService = new stubs.SpyLLMService(); + llmServiceProvider = new stubs.StubLLMServiceProvider(spyLLMService); + codeAnalyzer = new stubs.StubCodeAnalyzer(); unifiedDiffService = new stubs.SpyUnifiedDiffService(); diagnosticCollection = new FakeDiagnosticCollection(); - diagnosticCollection.set(sampleUri, [sampleDiagnostic1, sampleDiagnostic2]); + diagnosticCollection.set(sampleUri, [sampleDiagForSingleLine, sampleDiagThatSpansTwoLines]); diagnosticManager = new DiagnosticManagerImpl(diagnosticCollection); telemetryService = new stubs.SpyTelemetryService(); logger = new stubs.SpyLogger(); - fixSuggester = new stubs.SpyFixSuggester(); display = new stubs.SpyDisplay(); - a4dFixAction = new A4DFixAction(fixSuggester, unifiedDiffService, diagnosticManager, telemetryService, logger, display); + a4dFixAction = new A4DFixAction(llmServiceProvider, codeAnalyzer, unifiedDiffService, diagnosticManager, telemetryService, logger, display); }); it('When unified diff service cannot show diff, then return without trying to show diff', async () => { unifiedDiffService.verifyCanShowDiffReturnValue = false; - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); expect(display.displayWarningCallHistory).toHaveLength(0); expect(unifiedDiffService.showDiffCallHistory).toHaveLength(0); @@ -55,19 +60,20 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ commandName: 'sfdx__codeanalyzer_qf_no_fix_suggested', properties: { - commandSource: 'sfca.a4dFix', + commandSource: A4DFixAction.COMMAND, reason: 'unified_diff_cannot_be_shown' } }); }); - it('When no fix is suggested (i.e. null is returned), then return with info msg displayed', async () => { - fixSuggester.suggestFixReturnValue = null; + it('When diagnostic is not relevant (i.e. null is returned from suggestFix), then return with info msg displayed', async () => { + const staleDiagnostic: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleUri, new vscode.Range(0,0,0,1), 'ApexDoc'); + staleDiagnostic.markStale(); - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + await a4dFixAction.run(staleDiagnostic, sampleDocument); expect(display.displayInfoCallHistory).toHaveLength(1); - expect(display.displayInfoCallHistory[0].msg).toEqual(messages.agentforce.noFixSuggested); + expect(display.displayInfoCallHistory[0].msg).toEqual(messages.fixer.noFixSuggested); expect(unifiedDiffService.showDiffCallHistory).toHaveLength(0); expect(diagnosticCollection.get(sampleUri)).toHaveLength(2); // Should still be 2 @@ -76,44 +82,115 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ commandName: 'sfdx__codeanalyzer_qf_no_fix_suggested', properties: { - commandSource: 'sfca.a4dFix', + commandSource: A4DFixAction.COMMAND, languageType: 'apex', reason: 'empty' } }); }); + it('When error is thrown from the LLMServiceProvider, then display error message and send exception telemetry event', async () => { + llmServiceProvider = new stubs.ThrowingLLMServiceProvider(); + a4dFixAction = new A4DFixAction(llmServiceProvider, codeAnalyzer, unifiedDiffService, diagnosticManager, telemetryService, logger, display); + + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); + + expect(display.displayErrorCallHistory).toHaveLength(1); + expect(display.displayErrorCallHistory[0].msg).toContain('Error from getLLMService'); + + expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); + expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain( + 'Error from getLLMService'); + expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( + 'sfdx__eGPT_suggest_failure'); + expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( + A4DFixAction.COMMAND); + }); + it('When error is thrown while suggesting fix, then display error message and send exception telemetry event', async () => { - const fixSuggester: stubs.ThrowingFixSuggester = new stubs.ThrowingFixSuggester(); - a4dFixAction = new A4DFixAction(fixSuggester, unifiedDiffService, diagnosticManager, telemetryService, logger, display); + llmServiceProvider = new stubs.StubLLMServiceProvider(new stubs.ThrowingLLMService()); + a4dFixAction = new A4DFixAction(llmServiceProvider, codeAnalyzer, unifiedDiffService, diagnosticManager, telemetryService, logger, display); - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); expect(display.displayErrorCallHistory).toHaveLength(1); - expect(display.displayErrorCallHistory[0].msg).toContain('Error thrown from: suggestFix'); + expect(display.displayErrorCallHistory[0].msg).toContain('Error from callLLM'); expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain( - 'Error thrown from: suggestFix'); + 'Error from callLLM'); expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( 'sfdx__eGPT_suggest_failure'); expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( - 'sfca.a4dFix'); + A4DFixAction.COMMAND); }); - it('When fix is suggested, then the diff is displayed, the diagnostic is cleared, and a telemetry event is sent', async () => { - fixSuggester.suggestFixReturnValue = sampleFixSuggestion; + it('When error is thrown from Code Analyzer, then display error message and send exception telemetry event', async () => { + const throwingCodeAnalyzer: CodeAnalyzer = new stubs.ThrowingCodeAnalyzer(); + a4dFixAction = new A4DFixAction(llmServiceProvider, throwingCodeAnalyzer, unifiedDiffService, diagnosticManager, telemetryService, logger, display); + + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); + + expect(display.displayErrorCallHistory).toHaveLength(1); + expect(display.displayErrorCallHistory[0].msg).toContain('Error from getRuleDescriptionFor.'); + + expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); + expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain( + 'Error from getRuleDescriptionFor.'); + expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( + 'sfdx__eGPT_suggest_failure'); + expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( + A4DFixAction.COMMAND); + }); + + it('When llm response does not contain fixedCode field, then display error message and send exception telemetry event', async () => { + spyLLMService.callLLMReturnValue = '{"useless":3}'; + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); + + expect(display.displayErrorCallHistory).toHaveLength(1); + expect(display.displayErrorCallHistory[0].msg).toContain(`Response from LLM is missing the 'fixedCode' property.`); + + expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); + expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain( + `Response from LLM is missing the 'fixedCode' property.`); + expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( + 'sfdx__eGPT_suggest_failure'); + expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( + A4DFixAction.COMMAND); + }); + + it('When llm response is not valid JSON, then display error message and send exception telemetry event', async () => { + spyLLMService.callLLMReturnValue = 'oops - not json'; + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); + + expect(display.displayErrorCallHistory).toHaveLength(1); + expect(display.displayErrorCallHistory[0].msg).toContain(`Response from LLM is not valid JSON`); + + expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); + expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain( + `Response from LLM is not valid JSON`); + expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( + 'sfdx__eGPT_suggest_failure'); + expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( + A4DFixAction.COMMAND); + }); - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + it('When fix is suggested, then the diff is displayed, the diagnostic is cleared, and a telemetry event is sent', async () => { + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); // Diff is displayed expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); expect(unifiedDiffService.showDiffCallHistory[0].document).toEqual(sampleDocument); - expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual('someFixedCode\nsample content'); + // expect the first line to get replaced by the fix + expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual( + 'some code fix\n' + // < ---expect the first line to get replaced by the fix + 'that is multi-line\n' + + ' with spaces and such\n' + + ' within the content.'); // Diagnostic is cleared - expect(diagnosticCollection.get(sampleUri)).toEqual([ // sampleDiagnostic1 should be removed - sampleDiagnostic2 // but sampleDiagnostic2 should still remain + expect(diagnosticCollection.get(sampleUri)).toEqual([ // sampleDiagForSingleLine should be removed + sampleDiagThatSpansTwoLines // but sampleDiagThatSpansTwoLines should still remain ]); // Telemetry event is sent @@ -121,28 +198,27 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ commandName: 'sfdx__eGPT_suggest', properties: { - commandSource: 'sfca.a4dFix', + commandSource: A4DFixAction.COMMAND, completionNumLines: '1', languageType: 'apex', - ruleName: 'ApexDoc' + engineName: 'pmd', + ruleName: 'ApexAssertionsShouldIncludeMessage' } }); }); it('When fix is suggested with an explanation, then diff is displayed and explanation is given by an info message display', async () => { - fixSuggester.suggestFixReturnValue = new FixSuggestion({ - document: sampleDocument, - diagnostic: sampleDiagnostic2, - rangeToBeFixed: new vscode.Range(1, 0, 1, 17), - fixedCode: 'hello World' - }, 'This is some explanation'); + spyLLMService.callLLMReturnValue = '{"fixedCode": "hello World", "explanation": "This is some explanation"}'; - await a4dFixAction.run(sampleDocument, sampleDiagnostic2); + await a4dFixAction.run(sampleDiagThatSpansTwoLines, sampleDocument); // Diff is displayed expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); expect(unifiedDiffService.showDiffCallHistory[0].document).toEqual(sampleDocument); - expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual('some\nhello World'); + expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual( + 'This is some dummy content\n' + + 'hello World\n' + // Fix replaces both lines + ' within the content.'); expect(display.displayInfoCallHistory).toHaveLength(1); expect(display.displayInfoCallHistory[0].msg).toEqual('Fix Explanation: This is some explanation'); @@ -150,9 +226,7 @@ describe('Tests for A4DFixAction', () => { }); it('When fix is suggested, then the accept callback (when executed) sends a telemetry event', async () => { - fixSuggester.suggestFixReturnValue = sampleFixSuggestion; - - await a4dFixAction.run(sampleDocument, sampleDiagnostic2); + await a4dFixAction.run(sampleDiagThatSpansTwoLines, sampleDocument); expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); await unifiedDiffService.showDiffCallHistory[0].acceptCallback(); @@ -161,18 +235,17 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendCommandEventCallHistory[1]).toEqual({ commandName: 'sfdx__eGPT_accept', properties: { - commandSource: 'sfca.a4dFix', + commandSource: A4DFixAction.COMMAND, completionNumLines: '1', languageType: 'apex', - ruleName: 'ApexDoc' + engineName: 'pmd', + ruleName: 'UnusedLocalVariable' } }); }); it('When fix is suggested, then the reject callback (when executed) sends a telemetry event', async () => { - fixSuggester.suggestFixReturnValue = sampleFixSuggestion; - - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); await unifiedDiffService.showDiffCallHistory[0].rejectCallback(); @@ -181,21 +254,20 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendCommandEventCallHistory[1]).toEqual({ commandName: 'sfdx__eGPT_clear', properties: { - commandSource: 'sfca.a4dFix', + commandSource: A4DFixAction.COMMAND, completionNumLines: '1', languageType: 'apex', - ruleName: 'ApexDoc' + engineName: 'pmd', + ruleName: 'ApexAssertionsShouldIncludeMessage' } }); }); it('When fix is suggested, but diff tool throws exception, then display error message, restore diagnostic, and send exception telemetry event', async () => { const unifiedDiffService: stubs.ThrowingUnifiedDiffService = new stubs.ThrowingUnifiedDiffService(); - a4dFixAction = new A4DFixAction(fixSuggester, unifiedDiffService, diagnosticManager, telemetryService, logger, display); - - fixSuggester.suggestFixReturnValue = sampleFixSuggestion; + a4dFixAction = new A4DFixAction(llmServiceProvider, codeAnalyzer, unifiedDiffService, diagnosticManager, telemetryService, logger, display); - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); expect(display.displayErrorCallHistory).toHaveLength(1); expect(display.displayErrorCallHistory[0].msg).toContain('Error thrown from: showDiff'); @@ -206,23 +278,18 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( 'sfdx__eGPT_suggest_failure'); expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( - 'sfca.a4dFix'); + A4DFixAction.COMMAND); expect(diagnosticCollection.get(sampleUri)).toHaveLength(2); // Should still be 2 }); it('When fix suggested is exactly the same as the original code, then show info message saying that no fix was suggested', async () => { - fixSuggester.suggestFixReturnValue = new FixSuggestion({ - document: sampleDocument, - diagnostic: sampleDiagnostic1, - rangeToBeFixed: new vscode.Range(0, 0, 0, 4), - fixedCode: 'some' // same as before - });; - - await a4dFixAction.run(sampleDocument, sampleDiagnostic1); + // this fixed code is exactly the same as lines 2 and 3 - which sampleDiagThatSpansTwoLines's range gets extended to + spyLLMService.callLLMReturnValue = '{"fixedCode": "that is multi-line\\n with spaces and such"}'; + await a4dFixAction.run(sampleDiagThatSpansTwoLines, sampleDocument); expect(display.displayInfoCallHistory).toHaveLength(1); - expect(display.displayInfoCallHistory[0].msg).toEqual(messages.agentforce.noFixSuggested); + expect(display.displayInfoCallHistory[0].msg).toEqual(messages.fixer.noFixSuggested); expect(unifiedDiffService.showDiffCallHistory).toHaveLength(0); expect(diagnosticCollection.get(sampleUri)).toHaveLength(2); // Should still be 2 @@ -231,10 +298,105 @@ describe('Tests for A4DFixAction', () => { expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ commandName: 'sfdx__codeanalyzer_qf_no_fix_suggested', properties: { - commandSource: 'sfca.a4dFix', + commandSource: A4DFixAction.COMMAND, languageType: 'apex', reason: 'same_code' } }); }); -}); + + it('When codeAnalyzer returns description, then it is forwarded to the LLMService', async () => { + codeAnalyzer.getRuleDescriptionForReturnValue = 'some rule description'; + await a4dFixAction.run(sampleDiagForSingleLine, sampleDocument); + expect(spyLLMService.callLLMCallHistory).toHaveLength(1); + expect(spyLLMService.callLLMCallHistory[0].prompt).toContain('"ruleDescription": "some rule description"'); + }); + + it('When a rule should be sending in the full method context, confirm the context gets sent and the full method gets replaced', async () => { + const fileContent: string = + 'public class SomeClass {\n' + + ' public void someMethod() {\n' + + ' Blob hardCodedIV = Blob.valueOf(\'Hardcoded IV 123\');\n' + + ' Blob hardCodedKey = Blob.valueOf(\'0000000000000000\');\n' + + ' Blob data = Blob.valueOf(\'Data to be encrypted\');\n' + + ' Blob encrypted = Crypto.encrypt(\'AES128\', hardCodedKey, hardCodedIV, data);\n' + + ' }\n' + + '}'; + const document: vscode.TextDocument = createTextDocument(vscode.Uri.file('dummy.cls'), fileContent, 'apex'); + const diagnostic: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(vscode.Uri.file('dummy.cls'), + new vscode.Range(5, 50, 5, 62), 'ApexBadCrypto'); // Uses MethodScope + + spyLLMService.callLLMReturnValue = '{"fixedCode": "some replacement\\ncode here"}'; + + await a4dFixAction.run(diagnostic, document); + + expect(spyLLMService.callLLMCallHistory[0].prompt).toContain('"codeContext": " public void someMethod() {'); + + // Diff is displayed + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); + expect(unifiedDiffService.showDiffCallHistory[0].document).toEqual(document); + // expect the first line to get replaced by the fix + expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual( + 'public class SomeClass {\n' + + ' some replacement\n' + // < --- fix replaced entire method block + ' code here\n' + // <-- And notice the proper indenting! + '}'); + + // Telemetry event is sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ + commandName: 'sfdx__eGPT_suggest', + properties: { + commandSource: A4DFixAction.COMMAND, + completionNumLines: '2', + languageType: 'apex', + engineName: 'pmd', + ruleName: 'ApexBadCrypto' + } + }); + }) + + it('When a rule should be sending in the full class context, confirm the context gets sent and the full class gets replaced', async () => { + const fileContent: string = + '// This is some comment\n' + + 'public class FieldDeclarationsShouldBeAtStart {\n' + + ' public Integer instanceProperty { get; set; }\n' + + '\n' + + ' public void someMethod() {\n' + + ' }\n' + + '\n' + + ' public Integer anotherField; // bad\n' + + '}'; + const document: vscode.TextDocument = createTextDocument(vscode.Uri.file('dummy.cls'), fileContent, 'apex'); + const diagnostic: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(vscode.Uri.file('dummy.cls'), + new vscode.Range(7, 4, 7, 31), 'FieldDeclarationsShouldBeAtStart'); // Uses ClassScope + + spyLLMService.callLLMReturnValue = '{"fixedCode": "some replacement\\ncode here"}'; + + await a4dFixAction.run(diagnostic, document); + + expect(spyLLMService.callLLMCallHistory[0].prompt).toContain('"codeContext": "public class FieldDeclarationsShouldBeAtStart'); + + // Diff is displayed + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); + expect(unifiedDiffService.showDiffCallHistory[0].document).toEqual(document); + // expect the first line to get replaced by the fix + expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual( + '// This is some comment\n' + + 'some replacement\n' + // < --- fix replaced entire class block + 'code here'); + + // Telemetry event is sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ + commandName: 'sfdx__eGPT_suggest', + properties: { + commandSource: A4DFixAction.COMMAND, + completionNumLines: '2', + languageType: 'apex', + engineName: 'pmd', + ruleName: 'FieldDeclarationsShouldBeAtStart' + } + }); + }); +}); \ No newline at end of file diff --git a/src/test/unit/lib/agentforce/agentforce-code-action-provider.test.ts b/src/test/unit/lib/agentforce/agentforce-code-action-provider.test.ts index 88a84993..98da8802 100644 --- a/src/test/unit/lib/agentforce/agentforce-code-action-provider.test.ts +++ b/src/test/unit/lib/agentforce/agentforce-code-action-provider.test.ts @@ -1,22 +1,23 @@ import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts -import {AgentforceCodeActionProvider} from "../../../../lib/agentforce/agentforce-code-action-provider"; +import {A4DFixActionProvider} from "../../../../lib/agentforce/a4d-fix-action-provider"; import {SpyLLMService, SpyLogger, StubLLMServiceProvider} from "../../stubs"; import {StubCodeActionContext} from "../../vscode-stubs"; import {messages} from "../../../../lib/messages"; import {createTextDocument} from "jest-mock-vscode"; import {createSampleCodeAnalyzerDiagnostic} from "../../test-utils"; +import { A4DFixAction } from "../../../../lib/agentforce/a4d-fix-action"; describe('AgentforceCodeActionProvider Tests', () => { let spyLLMService: SpyLLMService; let llmServiceProvider: StubLLMServiceProvider; let spyLogger: SpyLogger; - let actionProvider: AgentforceCodeActionProvider; + let actionProvider: A4DFixActionProvider; beforeEach(() => { spyLLMService = new SpyLLMService(); llmServiceProvider = new StubLLMServiceProvider(spyLLMService); spyLogger = new SpyLogger(); - actionProvider = new AgentforceCodeActionProvider(llmServiceProvider, spyLogger); + actionProvider = new A4DFixActionProvider(llmServiceProvider, spyLogger); }); describe('provideCodeActions Tests', () => { @@ -35,21 +36,22 @@ describe('AgentforceCodeActionProvider Tests', () => { it('When a single supported diagnostic is in the context, then should return the one code action with correctly filled in fields', async () => { const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [supportedDiag1]}); - const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context, undefined); + const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context); expect(codeActions).toHaveLength(1); - expect(codeActions[0].title).toEqual(messages.agentforce.fixViolationWithA4D('ApexBadCrypto')); + const fixMsg: string = messages.fixer.applyFix('pmd', 'ApexBadCrypto'); + expect(codeActions[0].title).toEqual(fixMsg); expect(codeActions[0].kind).toEqual(vscode.CodeActionKind.QuickFix); expect(codeActions[0].diagnostics).toEqual([supportedDiag1]); expect(codeActions[0].command).toEqual({ - arguments: [sampleDocument, supportedDiag1], - command: 'sfca.a4dFix', - title: 'Fix Diagnostic Issue'}); + arguments: [supportedDiag1, sampleDocument], + command: A4DFixAction.COMMAND, + title: fixMsg}); }); it('When no supported diagnostic is in the context, then should return no code actions', async () => { const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [unsupportedDiag1]}); - const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context, undefined); + const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context); expect(codeActions).toHaveLength(0); }); @@ -58,7 +60,7 @@ describe('AgentforceCodeActionProvider Tests', () => { const context: vscode.CodeActionContext = new StubCodeActionContext({ diagnostics: [supportedDiag1, supportedDiag2, unsupportedDiag1, unsupportedDiag2, unsupportedDiag3, supportedDiag3] }); - const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context, undefined); + const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context); expect(codeActions).toHaveLength(3); expect(codeActions[0].diagnostics).toEqual([supportedDiag1]); @@ -69,8 +71,8 @@ describe('AgentforceCodeActionProvider Tests', () => { it('When the LLMService is unavailable, then warn once and return no code actions', async () => { llmServiceProvider.isLLMServiceAvailableReturnValue = false; const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [supportedDiag1]}); - const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context, undefined); - await actionProvider.provideCodeActions(sampleDocument, range, context, undefined); // Sanity check that multiple calls do not produce additional warnings + const codeActions: vscode.CodeAction[] = await actionProvider.provideCodeActions(sampleDocument, range, context); + await actionProvider.provideCodeActions(sampleDocument, range, context); // Sanity check that multiple calls do not produce additional warnings expect(codeActions).toHaveLength(0); expect(spyLogger.warnCallHistory).toHaveLength(1); diff --git a/src/test/unit/lib/agentforce/agentforce-violation-fixer.test.ts b/src/test/unit/lib/agentforce/agentforce-violation-fixer.test.ts deleted file mode 100644 index 2f1a7541..00000000 --- a/src/test/unit/lib/agentforce/agentforce-violation-fixer.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts -import { - SpyLLMService, - SpyLogger, - StubCodeAnalyzer, - StubLLMServiceProvider, ThrowingCodeAnalyzer, - ThrowingLLMService, - ThrowingLLMServiceProvider -} from "../../stubs"; -import {AgentforceViolationFixer} from "../../../../lib/agentforce/agentforce-violation-fixer"; -import {createTextDocument} from 'jest-mock-vscode' -import {FixSuggestion} from "../../../../lib/fix-suggestion"; -import {CodeAnalyzerDiagnostic} from "../../../../lib/diagnostics"; -import {createSampleCodeAnalyzerDiagnostic} from "../../test-utils"; - -describe('AgentforceViolationFixer Tests', () => { - let spyLLMService: SpyLLMService; - let llmServiceProvider: StubLLMServiceProvider; - let codeAnalyzer: StubCodeAnalyzer; - let spyLogger: SpyLogger; - let violationFixer: AgentforceViolationFixer; - - beforeEach(() => { - spyLLMService = new SpyLLMService(); - llmServiceProvider = new StubLLMServiceProvider(spyLLMService); - codeAnalyzer = new StubCodeAnalyzer(); - spyLogger = new SpyLogger(); - violationFixer = new AgentforceViolationFixer(llmServiceProvider, codeAnalyzer, spyLogger); - }); - - describe('suggestFix Tests', () => { - const sampleContent: string = - 'This is some dummy content\n' + - 'that is multi-line\n' + - ' with spaces and such\n' + - ' within the content.'; - const sampleDocument: vscode.TextDocument = createTextDocument(vscode.Uri.file('dummy.cls'), sampleContent, 'apex'); - const sampleDiagnostic: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(vscode.Uri.file('dummy.cls'), - new vscode.Range(0, 8, 1, 7), 'ApexDangerousMethods'); // Using a rule that just uses the ViolationScope - - it('When response is valid JSON with fixedCode and an explanation, then return the fix suggestion correctly', async () => { - spyLLMService.callLLMReturnValue = '{"fixedCode": "some code fix", "explanation": "some explanation"}'; - const fixSuggestion: FixSuggestion = await violationFixer.suggestFix(sampleDocument, sampleDiagnostic); - expect(fixSuggestion).not.toBeNull(); - expect(fixSuggestion.codeFixData.document).toEqual(sampleDocument); - expect(fixSuggestion.codeFixData.diagnostic).toEqual(sampleDiagnostic); - expect(fixSuggestion.codeFixData.rangeToBeFixed).toEqual(new vscode.Range(0, 0, 1, 18)); // Should be the full violation context - expect(fixSuggestion.codeFixData.fixedCode).toEqual('some code fix'); - expect(fixSuggestion.hasExplanation()).toEqual(true); - expect(fixSuggestion.getExplanation()).toEqual('some explanation'); - }); - - it('When a rule should be sending in additional context, confirm the range gets adjusted appropriately', async () => { - const fileContent: string = - 'public class SomeClass {\n' + - ' public void someMethod() {\n' + - ' Blob hardCodedIV = Blob.valueOf(\'Hardcoded IV 123\');\n' + - ' Blob hardCodedKey = Blob.valueOf(\'0000000000000000\');\n' + - ' Blob data = Blob.valueOf(\'Data to be encrypted\');\n' + - ' Blob encrypted = Crypto.encrypt(\'AES128\', hardCodedKey, hardCodedIV, data);\n' + - ' }\n' + - '}'; - const document: vscode.TextDocument = createTextDocument(vscode.Uri.file('dummy.cls'), fileContent, 'apex'); - const diagnostic: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(vscode.Uri.file('dummy.cls'), - new vscode.Range(5, 50, 5, 62), 'ApexBadCrypto'); // Uses MethodScope - - spyLLMService.callLLMReturnValue = '{"fixedCode": "some fixed code"}'; - - const fixSuggestion: FixSuggestion = await violationFixer.suggestFix(document, diagnostic); - expect(fixSuggestion).not.toBeNull(); - expect(fixSuggestion.codeFixData.document).toEqual(document); - expect(fixSuggestion.codeFixData.diagnostic).toEqual(diagnostic); - expect(fixSuggestion.codeFixData.rangeToBeFixed).toEqual(new vscode.Range(1, 0, 6, 5)); // Should be the full method context - expect(fixSuggestion.codeFixData.fixedCode).toEqual('some fixed code'); - }); - - it('When response is valid JSON with fixedCode but without an explanation, then return the fix suggestion correctly', async () => { - spyLLMService.callLLMReturnValue = '{"fixedCode": "some code fix"}'; - const fixSuggestion: FixSuggestion = await violationFixer.suggestFix(sampleDocument, sampleDiagnostic); - expect(fixSuggestion).not.toBeNull(); - expect(fixSuggestion.hasExplanation()).toEqual(false); - expect(fixSuggestion.getExplanation()).toEqual(''); - expect(fixSuggestion.codeFixData.fixedCode).toEqual('some code fix'); - }); - - it('When response is valid JSON but without fixedCode, then throw exception', async () => { - spyLLMService.callLLMReturnValue = '{"useless":3}'; - await expect(violationFixer.suggestFix(sampleDocument, sampleDiagnostic)).rejects.toThrow( - 'Response from LLM is missing the \'fixedCode\' property.'); - }); - - it('When response is invalid JSON, then throw exception', async () => { - spyLLMService.callLLMReturnValue = 'oops - not json'; - await expect(violationFixer.suggestFix(sampleDocument, sampleDiagnostic)).rejects.toThrow( - 'Response from LLM is not valid JSON'); - }); - - it('When LLMServiceProvider throws an exception, then throw exception', async () => { - violationFixer = new AgentforceViolationFixer(new ThrowingLLMServiceProvider(), codeAnalyzer, spyLogger); - await expect(violationFixer.suggestFix(sampleDocument, sampleDiagnostic)).rejects.toThrow( - 'Error from getLLMService'); - }); - - it('When LLMService throws an exception, then throw exception', async () => { - llmServiceProvider = new StubLLMServiceProvider(new ThrowingLLMService()); - violationFixer = new AgentforceViolationFixer(llmServiceProvider, codeAnalyzer, spyLogger); - await expect(violationFixer.suggestFix(sampleDocument, sampleDiagnostic)).rejects.toThrow( - 'Error from callLLM'); - }); - - it('When diagnostic is associated with an unsupported rule, then throw exception', async () => { - const diagWithUnsupportedRule: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(vscode.Uri.file('dummy.cls'), - new vscode.Range(0, 8, 1, 7), 'SomeRandomRule'); - await expect(violationFixer.suggestFix(sampleDocument, diagWithUnsupportedRule)).rejects.toThrow( - 'Unsupported rule: SomeRandomRule'); - }); - - it('When codeAnalyzer returns description, then it is forwarded to the LLMService', async () => { - codeAnalyzer.getRuleDescriptionForReturnValue = 'some rule description'; - await violationFixer.suggestFix(sampleDocument, sampleDiagnostic); - expect(spyLLMService.callLLMCallHistory).toHaveLength(1); - expect(spyLLMService.callLLMCallHistory[0].prompt).toContain('"ruleDescription": "some rule description"'); - }); - - it('When codeAnalyzer throws error during call to getRuleDescription, then throw exception', async () => { - violationFixer = new AgentforceViolationFixer(llmServiceProvider, new ThrowingCodeAnalyzer(), spyLogger); - await expect(violationFixer.suggestFix(sampleDocument, sampleDiagnostic)).rejects.toThrow( - 'Error from getRuleDescriptionFor.'); - }) - }); -}); diff --git a/src/test/unit/lib/fixes-code-action-provider.test.ts b/src/test/unit/lib/apply-violation-fixes-action-provider.test.ts similarity index 54% rename from src/test/unit/lib/fixes-code-action-provider.test.ts rename to src/test/unit/lib/apply-violation-fixes-action-provider.test.ts index ec9240d2..5b00eab1 100644 --- a/src/test/unit/lib/fixes-code-action-provider.test.ts +++ b/src/test/unit/lib/apply-violation-fixes-action-provider.test.ts @@ -1,17 +1,16 @@ import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts -import { createSampleCodeAnalyzerDiagnostic } from '../test-utils'; +import { createSampleViolation } from '../test-utils'; import { createTextDocument } from "jest-mock-vscode"; import { StubCodeActionContext } from "../vscode-stubs"; -import { FixesCodeActionProvider } from "../../../lib/fixes-code-action-provider"; +import { ApplyViolationFixesAction } from "../../../lib/apply-violation-fixes-action"; +import { ApplyViolationFixesActionProvider } from "../../../lib/apply-violation-fixes-action-provider"; import { CodeAnalyzerDiagnostic } from "../../../lib/diagnostics"; -const MAX_COL: number = Number.MAX_SAFE_INTEGER; - -describe('PMDSupressionsCodeActionProvider Tests', () => { - let actionProvider: FixesCodeActionProvider; +describe('ApplyViolationFixesActionProvider Tests', () => { + let actionProvider: ApplyViolationFixesActionProvider; beforeEach(() => { - actionProvider = new FixesCodeActionProvider(); + actionProvider = new ApplyViolationFixesActionProvider(); }); describe('provideCodeActions Tests', () => { @@ -52,33 +51,37 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { `}`; const sampleApexDocument: vscode.TextDocument = createTextDocument(sampleApexUri, sampleApexContent, 'apex'); - const sampleDiag1Range: vscode.Range = new vscode.Range(3, 0, 3, MAX_COL); - const sampleDiag1: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag1Range, 'AvoidUsingSchemaGetGlobalDescribe', 'apexguru'); // Note that these rule names are made up right now - sampleDiag1.relatedInformation = [ // TODO: Replace this since it was a temporary way for pilot to store before and after code for suggestions - new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), - `Schema.DescribeSObjectResult opportunityDescribe = Schema.getGlobalDescribe().get('Opportunity').getDescribe();`), - new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), - `Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();`) - ]; - const sampleDiag2Range: vscode.Range = new vscode.Range(8, 0, 8, MAX_COL); - const sampleDiag2: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag2Range, 'AvoidSOQLInLoop', 'apexguru'); - const sampleDiag3Range: vscode.Range = new vscode.Range(12, 0, 12, MAX_COL); - const sampleDiag3: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag3Range, 'AvoidDMLInLoop', 'apexguru'); - const sampleDiag4Range: vscode.Range = new vscode.Range(17, 0, 17, MAX_COL); - const sampleDiag4: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag4Range, 'AvoidSOQLWithNegativeExpression', 'apexguru'); - const sampleDiag5Range: vscode.Range = new vscode.Range(21, 0, 21, MAX_COL); - const sampleDiag5: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag5Range, 'AvoidSOQLWithoutWhereClauseOrLimit', 'apexguru'); - const sampleDiag6Range: vscode.Range = new vscode.Range(25, 0, 25, MAX_COL); - const sampleDiag6: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag6Range, 'AvoidUsingSObjectsToInBind', 'apexguru'); - sampleDiag6.relatedInformation = [ // TODO: Replace this since it was a temporary way for pilot to store before and after code for suggestions - new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), - `[SELECT Id, FirstName, LastName FROM Contact WHERE AccountId IN :accounts]`), - new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), - `Map accountsMap = new Map(accounts);\n` + - `//Inside the SOQL: convert "accounts" into "accountsMap.keySet()"`) - ]; - const sampleDiag7Range: vscode.Range = new vscode.Range(29, 0, 29, MAX_COL); - const sampleDiag7: vscode.Diagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag7Range, 'AvoidSOQLWithWildcardFilters', 'apexguru'); + const sampleDiag1: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 4 }, 'AvoidUsingSchemaGetGlobalDescribe', 'apexguru', // Note that these rule names are made up right now + [{ + location: { file: sampleApexUri.fsPath, startLine: 4, startColumn: 9 }, + fixedCode: 'Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe()' + }] + )) + const sampleDiag2: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 9 }, 'AvoidSOQLInLoop', 'apexguru' + )) + const sampleDiag3: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 13 }, 'AvoidDMLInLoop', 'apexguru' + )) + const sampleDiag4: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 18 }, 'AvoidSOQLWithNegativeExpression', 'apexguru' + )) + const sampleDiag5: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 22 }, 'AvoidSOQLWithoutWhereClauseOrLimit', 'apexguru' + )) + const sampleDiag6: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 26 }, 'AvoidUsingSObjectsToInBind', 'apexguru', + [{ + location: { file: sampleApexUri.fsPath, startLine: 26 }, + fixedCode: `Map accountsMap = new Map(accounts);\n` + + `//Inside the SOQL: convert "accounts" into "accountsMap.keySet()"` + }] + + )) + const sampleDiag7: vscode.Diagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 30 }, 'AvoidSOQLWithWildcardFilters', 'apexguru' + )) // TODO: This test is temporary (as it is tied to the apex guru pilot code) and will be generalized soon. @@ -92,30 +95,27 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { expect(codeActions).toHaveLength(2); // Validate the first one is associated with diag 1 - expect(codeActions[0].title).toEqual("Insert ApexGuru suggestions."); + expect(codeActions[0].title).toEqual("Fix 'apexguru.AvoidUsingSchemaGetGlobalDescribe' using Code Analyzer"); expect(codeActions[0].diagnostics).toEqual([sampleDiag1]); - expect(codeActions[0].command.command).toEqual("sfca.includeApexGuruSuggestions"); - expect(codeActions[0].command.arguments).toEqual([sampleApexDocument, sampleDiag1Range.start, - `Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();\n`]) + expect(codeActions[0].command.command).toEqual(ApplyViolationFixesAction.COMMAND); + expect(codeActions[0].command.arguments).toEqual([sampleDiag1, sampleApexDocument]) // Validate the second is associated with diag 6 - expect(codeActions[1].title).toEqual("Insert ApexGuru suggestions."); + expect(codeActions[1].title).toEqual("Fix 'apexguru.AvoidUsingSObjectsToInBind' using Code Analyzer"); expect(codeActions[1].diagnostics).toEqual([sampleDiag6]); - expect(codeActions[1].command.command).toEqual("sfca.includeApexGuruSuggestions"); - expect(codeActions[1].command.arguments).toEqual([sampleApexDocument, sampleDiag6Range.start, - `Map accountsMap = new Map(accounts);\n` + - `//Inside the SOQL: convert "accounts" into "accountsMap.keySet()"\n`]) + expect(codeActions[1].command.command).toEqual(ApplyViolationFixesAction.COMMAND); + expect(codeActions[1].command.arguments).toEqual([sampleDiag6, sampleApexDocument]); }); it('stale diagnostics are filtered out', () => { - const staleDiag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic(sampleApexUri, sampleDiag1Range, 'Dummy', 'apexguru'); - staleDiag.relatedInformation = [ // TODO: Replace this since it was a temporary way for pilot to store before and after code for suggestions - new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), - `before code`), - new vscode.DiagnosticRelatedInformation(new vscode.Location(sampleApexUri, new vscode.Position(0, 0)), - `after code`) - ]; + const staleDiag: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 4 }, 'AvoidUsingSchemaGetGlobalDescribe', 'apexguru', + [{ + location: { file: sampleApexUri.fsPath, startLine: 4, startColumn: 9 }, + fixedCode: 'Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe()' + }] + )); staleDiag.markStale(); const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [sampleDiag1, staleDiag]}); @@ -141,7 +141,7 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { it('Valid diagnostics not within the selected range should be filtered out', () => { const context: vscode.CodeActionContext = new StubCodeActionContext({diagnostics: [ sampleDiag1, sampleDiag2, sampleDiag3, sampleDiag4, sampleDiag5, sampleDiag6, sampleDiag7]}); - const selectedRange: vscode.Range = sampleDiag2Range; // select the range of diag 2 (which isn't valid because it has no suggestions) + const selectedRange: vscode.Range = sampleDiag2.range; // select the range of diag 2 (which isn't valid because it has no suggestions) const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument, selectedRange, context); expect(codeActions).toHaveLength(0); diff --git a/src/test/unit/lib/apply-violation-fixes-action.test.ts b/src/test/unit/lib/apply-violation-fixes-action.test.ts new file mode 100644 index 00000000..fe4902b7 --- /dev/null +++ b/src/test/unit/lib/apply-violation-fixes-action.test.ts @@ -0,0 +1,216 @@ +import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts + +import * as stubs from "../stubs"; +import { createTextDocument } from "jest-mock-vscode"; +import { createSampleViolation } from "../test-utils"; +import { CodeAnalyzerDiagnostic, DiagnosticManager, DiagnosticManagerImpl } from "../../../lib/diagnostics"; +import { FakeDiagnosticCollection } from "../vscode-stubs"; +import { ApplyViolationFixesAction } from "../../../lib/apply-violation-fixes-action"; +import { messages } from "../../../lib/messages"; + + +describe('Tests for ApplyViolationFixesAction', () => { + const sampleUri: vscode.Uri = vscode.Uri.file('/someFile.cls'); + const sampleContent: string = + `public class ConsolidatedClass {\n` + + ` public static void processAccountsAndContacts(List accounts) {\n` + + ` // Antipattern [Avoid using Schema.getGlobalDescribe() in Apex]: (has fix)\n` + + ` Schema.DescribeSObjectResult opportunityDescribe = Schema.getGlobalDescribe().get('Opportunity').getDescribe();\n` + + ` System.debug('Opportunity Describe: ' + opportunityDescribe);\n` + + `\n` + + ` for (Account acc : accounts) {\n` + + ` // Antipattern [SOQL in loop]:\n` + + ` List contacts = [SELECT Id, Email FROM Contact WHERE AccountId = :acc.Id];\n` + + ` System.debug('Contacts: ' + contacts);\n` + + ` }\n` + + `}`; + + const sampleDocument: vscode.TextDocument = createTextDocument(sampleUri, sampleContent, 'apex'); + const sampleDiag1: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleUri.fsPath, startLine: 4 }, 'AvoidUsingSchemaGetGlobalDescribe', 'apexguru', // Note that these rule names are made up right now + [{ + location: { file: sampleUri.fsPath, startLine: 4, startColumn: 9 }, + fixedCode: 'Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();' + }] + )); + const sampleDiag2: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( // not relevant because it has no fixes + { file: sampleUri.fsPath, startLine: 9 }, 'AvoidSOQLInLoop', 'apexguru' + )); + + let unifiedDiffService: stubs.SpyUnifiedDiffService; + let diagnosticCollection: vscode.DiagnosticCollection; + let diagnosticManager: DiagnosticManager; + let telemetryService: stubs.SpyTelemetryService; + let logger: stubs.SpyLogger; + let display: stubs.SpyDisplay; + let applyViolationFixesAction: ApplyViolationFixesAction; + + beforeEach(() => { + unifiedDiffService = new stubs.SpyUnifiedDiffService(); + diagnosticCollection = new FakeDiagnosticCollection(); + diagnosticCollection.set(sampleUri, [sampleDiag1, sampleDiag2]); + diagnosticManager = new DiagnosticManagerImpl(diagnosticCollection); + telemetryService = new stubs.SpyTelemetryService(); + logger = new stubs.SpyLogger(); + display = new stubs.SpyDisplay(); + applyViolationFixesAction = new ApplyViolationFixesAction(unifiedDiffService, diagnosticManager, telemetryService, logger, display); + }); + + it('When unified diff service cannot show diff, then return without trying to show diff', async () => { + unifiedDiffService.verifyCanShowDiffReturnValue = false; + + await applyViolationFixesAction.run(sampleDiag1, sampleDocument); + + expect(display.displayWarningCallHistory).toHaveLength(0); + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(0); + + // Telemetry event is sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ + commandName: 'sfdx__codeanalyzer_qf_no_fix_suggested', + properties: { + commandSource: ApplyViolationFixesAction.COMMAND, + reason: 'unified_diff_cannot_be_shown' + } + }); + }); + + it('When diagnostic is not relevant (i.e. null is returned from suggestFix), then return with info msg displayed', async () => { + await applyViolationFixesAction.run(sampleDiag2, sampleDocument); // sampleDiag2 is not relevant + + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toEqual(messages.fixer.noFixSuggested); + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(0); + expect(diagnosticCollection.get(sampleUri)).toHaveLength(2); // Should still be 2 + + // Telemetry event is sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ + commandName: 'sfdx__codeanalyzer_qf_no_fix_suggested', + properties: { + commandSource: ApplyViolationFixesAction.COMMAND, + languageType: 'apex', + reason: 'empty' + } + }); + }); + + it('When fix is suggested, then the diff is displayed, the diagnostic is cleared, and a telemetry event is sent', async () => { + await applyViolationFixesAction.run(sampleDiag1, sampleDocument); + + // Diff is displayed + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); + expect(unifiedDiffService.showDiffCallHistory[0].document).toEqual(sampleDocument); + // expect the line to get replaced by the fix correctly + expect(unifiedDiffService.showDiffCallHistory[0].newCode).toEqual( + sampleContent.replace( + `Schema.DescribeSObjectResult opportunityDescribe = Schema.getGlobalDescribe().get('Opportunity').getDescribe();`, + `Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();` + )); + + // Diagnostic is cleared + expect(diagnosticCollection.get(sampleUri)).toEqual([ // sampleDiag1 should be removed + sampleDiag2 // but sampleDiag2 should still remain + ]); + + // Telemetry event is sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ + commandName: 'sfdx__codeanalyzer_qf_fix_suggested', + properties: { + commandSource: ApplyViolationFixesAction.COMMAND, + completionNumLines: '1', + languageType: 'apex', + engineName: 'apexguru', + ruleName: 'AvoidUsingSchemaGetGlobalDescribe' + } + }); + }); + + it('When fix is suggested, then the accept callback (when executed) sends a telemetry event', async () => { + await applyViolationFixesAction.run(sampleDiag1, sampleDocument); + + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); + await unifiedDiffService.showDiffCallHistory[0].acceptCallback(); + + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(2); + expect(telemetryService.sendCommandEventCallHistory[1]).toEqual({ + commandName: 'sfdx__codeanalyzer_qf_fix_accepted', + properties: { + commandSource: ApplyViolationFixesAction.COMMAND, + completionNumLines: '1', + languageType: 'apex', + engineName: 'apexguru', + ruleName: 'AvoidUsingSchemaGetGlobalDescribe' + } + }); + }); + + it('When fix is suggested, then the reject callback (when executed) sends a telemetry event', async () => { + await applyViolationFixesAction.run(sampleDiag1, sampleDocument); + + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(1); + await unifiedDiffService.showDiffCallHistory[0].rejectCallback(); + + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(2); + expect(telemetryService.sendCommandEventCallHistory[1]).toEqual({ + commandName: 'sfdx__codeanalyzer_qf_fix_rejected', + properties: { + commandSource: ApplyViolationFixesAction.COMMAND, + completionNumLines: '1', + languageType: 'apex', + engineName: 'apexguru', + ruleName: 'AvoidUsingSchemaGetGlobalDescribe' + } + }); + }); + + it('When fix is suggested, but diff tool throws exception, then display error message, restore diagnostic, and send exception telemetry event', async () => { + const unifiedDiffService: stubs.ThrowingUnifiedDiffService = new stubs.ThrowingUnifiedDiffService(); + applyViolationFixesAction = new ApplyViolationFixesAction(unifiedDiffService, diagnosticManager, telemetryService, logger, display); + + await applyViolationFixesAction.run(sampleDiag1, sampleDocument); + + expect(display.displayErrorCallHistory).toHaveLength(1); + expect(display.displayErrorCallHistory[0].msg).toContain('Error thrown from: showDiff'); + + expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); + expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain( + 'Error thrown from: showDiff'); + expect(telemetryService.sendExceptionCallHistory[0].name).toEqual( + 'sfdx__codeanalyzer_qf_fix_suggestion_failed'); + expect(telemetryService.sendExceptionCallHistory[0].properties['executedCommand']).toEqual( + ApplyViolationFixesAction.COMMAND); + + expect(diagnosticCollection.get(sampleUri)).toHaveLength(2); // Should still be 2 + }); + + it('When fix suggested is exactly the same as the original code, then show info message saying that no fix was suggested', async () => { + // this fixed code is exactly the same as lines 2 and 3 - which sampleDiagThatSpansTwoLines's range gets extended to + const diag: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleUri.fsPath, startLine: 1, endLine: 5 }, 'SomeRuleName', 'SomeEngine', + [{ + location: { file: sampleUri.fsPath, startLine: 1, startColumn: 8, endColumn: 13 }, + fixedCode: 'class' // exact same code + }] + )) + await applyViolationFixesAction.run(diag, sampleDocument); + + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toEqual(messages.fixer.noFixSuggested); + expect(unifiedDiffService.showDiffCallHistory).toHaveLength(0); + expect(diagnosticCollection.get(sampleUri)).toHaveLength(2); // Should still be 2 + + // Telemetry event is sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0]).toEqual({ + commandName: 'sfdx__codeanalyzer_qf_no_fix_suggested', + properties: { + commandSource: ApplyViolationFixesAction.COMMAND, + languageType: 'apex', + reason: 'same_code' + } + }); + }); +}); + diff --git a/src/test/unit/lib/diagnostics.test.ts b/src/test/unit/lib/diagnostics.test.ts index 88d4e88f..39cb01b6 100644 --- a/src/test/unit/lib/diagnostics.test.ts +++ b/src/test/unit/lib/diagnostics.test.ts @@ -102,17 +102,17 @@ describe('Tests for the CodeAnalyzerDiagnostic class', () => { new vscode.Location( vscode.Uri.file('/path/to/some/someFile.cls'), new vscode.Range(0, 1, 2, 3)), - undefined)); + '')); expect(diag.relatedInformation[1]).toEqual(new vscode.DiagnosticRelatedInformation( new vscode.Location( vscode.Uri.file('/path/to/some/someFileButNoLineInfo.cls'), new vscode.Range(0, 0, 0, Number.MAX_SAFE_INTEGER)), - undefined)); + '')); expect(diag.relatedInformation[2]).toEqual(new vscode.DiagnosticRelatedInformation( new vscode.Location( vscode.Uri.file('/path/to/some/someFileWithSomeLineInfo.cls'), new vscode.Range(0, 0, 17, Number.MAX_SAFE_INTEGER)), - undefined)); + '')); }); it.each([ diff --git a/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts b/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts index 63d29400..b8f72f1f 100644 --- a/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts +++ b/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts @@ -53,7 +53,7 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { expect(codeActions).toHaveLength(2); // Validate the line level suppression action - expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + expect(codeActions[0].title).toEqual("Suppress all 'pmd' violations on this line"); const lineEdits: vscode.TextEdit[] = codeActions[0].edit.get(sampleApexUri); expect(lineEdits).toHaveLength(1); expect(lineEdits[0].range).toEqual(new vscode.Range(4, MAX_COL, 4, MAX_COL)); @@ -62,7 +62,7 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { // Validate the class level supression action - expect(codeActions[1].title).toEqual("Suppress 'PMD.EmptyCatchBlock' on this class."); + expect(codeActions[1].title).toEqual("Suppress 'pmd.EmptyCatchBlock' on this class"); const classEdits: vscode.TextEdit[] = codeActions[1].edit.get(sampleApexUri); expect(classEdits).toHaveLength(1); expect(classEdits[0].range).toEqual(new vscode.Range(0, 0, 0, 0)); @@ -76,10 +76,10 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); expect(codeActions).toHaveLength(4); - expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); // TODO: We should really say the line number here to avoid confusion - expect(codeActions[1].title).toEqual("Suppress 'PMD.ApexDoc' on this class."); - expect(codeActions[2].title).toEqual("Suppress all PMD violations on this line."); // TODO: We should really say the line number here to avoid confusion - expect(codeActions[3].title).toEqual("Suppress 'PMD.EmptyCatchBlock' on this class."); + expect(codeActions[0].title).toEqual("Suppress all 'pmd' violations on this line"); // TODO: We should really say the line number here to avoid confusion + expect(codeActions[1].title).toEqual("Suppress 'pmd.ApexDoc' on this class"); + expect(codeActions[2].title).toEqual("Suppress all 'pmd' violations on this line"); // TODO: We should really say the line number here to avoid confusion + expect(codeActions[3].title).toEqual("Suppress 'pmd.EmptyCatchBlock' on this class"); }); it('When multiple valid pmd diagnostics are on the same line, then we only return 1 of the line suppressing diagnostics', () => { @@ -90,9 +90,9 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); expect(codeActions).toHaveLength(3); - expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); - expect(codeActions[1].title).toEqual("Suppress 'PMD.DummyRule1' on this class."); - expect(codeActions[2].title).toEqual("Suppress 'PMD.DummyRule2' on this class."); + expect(codeActions[0].title).toEqual("Suppress all 'pmd' violations on this line"); + expect(codeActions[1].title).toEqual("Suppress 'pmd.DummyRule1' on this class"); + expect(codeActions[2].title).toEqual("Suppress 'pmd.DummyRule2' on this class"); }); it('When a valid pmd diagnostic exists in a class that already has an existing SuppressWarning annotation, then 2 code action appends to it correctly', () => { @@ -103,7 +103,7 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { expect(codeActions).toHaveLength(2); // Validate the line level suppression action - expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); + expect(codeActions[0].title).toEqual("Suppress all 'pmd' violations on this line"); const lineEdits: vscode.TextEdit[] = codeActions[0].edit.get(sampleApexUri); expect(lineEdits).toHaveLength(1); expect(lineEdits[0].range).toEqual(new vscode.Range(2, MAX_COL, 2, MAX_COL)); @@ -112,7 +112,7 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { // Validate the class level supression action - expect(codeActions[1].title).toEqual("Suppress 'PMD.ApexDoc' on this class."); + expect(codeActions[1].title).toEqual("Suppress 'pmd.ApexDoc' on this class"); const classEdits: vscode.TextEdit[] = codeActions[1].edit.get(sampleApexUri); expect(classEdits).toHaveLength(1); expect(classEdits[0].range).toEqual(new vscode.Range(0, 0, 0, 40)); @@ -148,8 +148,8 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); expect(codeActions).toHaveLength(2); - expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); - expect(codeActions[1].title).toEqual("Suppress 'PMD.DummyRule2' on this class."); + expect(codeActions[0].title).toEqual("Suppress all 'pmd' violations on this line"); + expect(codeActions[1].title).toEqual("Suppress 'pmd.DummyRule2' on this class"); }); it('stale diagnostics are filtered out', () => { @@ -163,8 +163,8 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { const codeActions: vscode.CodeAction[] = actionProvider.provideCodeActions(sampleApexDocument1, selectedRange, context); expect(codeActions).toHaveLength(2); - expect(codeActions[0].title).toEqual("Suppress all PMD violations on this line."); - expect(codeActions[1].title).toEqual("Suppress 'PMD.DummyRule1' on this class."); + expect(codeActions[0].title).toEqual("Suppress all 'pmd' violations on this line"); + expect(codeActions[1].title).toEqual("Suppress 'pmd.DummyRule1' on this class"); }); it('Diagnostics which are not CodeAnalyzerDiagnostic instances are filtered out', () => { diff --git a/src/test/unit/stubs.ts b/src/test/unit/stubs.ts index fa62f577..84faaf88 100644 --- a/src/test/unit/stubs.ts +++ b/src/test/unit/stubs.ts @@ -3,11 +3,10 @@ import * as vscode from "vscode";// The vscode module is mocked out. See: script import {TelemetryService} from "../../lib/external-services/telemetry-service"; import {Logger} from "../../lib/logger"; import {LLMService, LLMServiceProvider} from "../../lib/external-services/llm-service"; -import {CodeAnalyzerDiagnostic, Violation} from "../../lib/diagnostics"; +import {Violation} from "../../lib/diagnostics"; import {Display, DisplayButton} from "../../lib/display"; import {UnifiedDiffService} from "../../lib/unified-diff-service"; import {TextDocument} from "vscode"; -import {FixSuggester, FixSuggestion} from "../../lib/fix-suggestion"; import {SettingsManager} from "../../lib/settings"; import {CodeAnalyzer} from "../../lib/code-analyzer"; import {ProgressEvent, ProgressReporter, TaskWithProgress, TaskWithProgressRunner} from "../../lib/progress"; @@ -232,23 +231,6 @@ export class ThrowingUnifiedDiffService implements UnifiedDiffService { } -export class SpyFixSuggester implements FixSuggester { - suggestFixReturnValue: FixSuggestion | null = null; - suggestFixCallHistory: { document: TextDocument, diagnostic: CodeAnalyzerDiagnostic }[] = []; - - suggestFix(document: TextDocument, diagnostic: CodeAnalyzerDiagnostic): Promise { - this.suggestFixCallHistory.push({document, diagnostic}); - return Promise.resolve(this.suggestFixReturnValue); - } -} - -export class ThrowingFixSuggester implements FixSuggester { - suggestFix(_document: TextDocument, _diagnostic: CodeAnalyzerDiagnostic): Promise { - throw new Error('Error thrown from: suggestFix'); - } -} - - export class StubSettingsManager implements SettingsManager { // ================================================================================================================= diff --git a/src/test/unit/test-utils.ts b/src/test/unit/test-utils.ts index 83ca2a5b..3a365b96 100644 --- a/src/test/unit/test-utils.ts +++ b/src/test/unit/test-utils.ts @@ -1,27 +1,31 @@ import * as vscode from "vscode"; -import {CodeAnalyzerDiagnostic, Violation} from "../../lib/diagnostics"; +import {CodeAnalyzerDiagnostic, CodeLocation, Fix, Suggestion} from "../../lib/diagnostics"; export function createSampleCodeAnalyzerDiagnostic(uri: vscode.Uri, range: vscode.Range, ruleName: string = 'someRule', engineName: string = 'pmd'): CodeAnalyzerDiagnostic { - const sampleViolation: Violation = { + return CodeAnalyzerDiagnostic.fromViolation(createSampleViolation({ + file: uri.fsPath, + startLine: range.start.line + 1, // Violations are 1 based while ranges are 0 based, so adjusting for this + startColumn: range.start.character + 1, + endLine: range.end.line + 1, + endColumn: range.end.character + 1 + }, + ruleName, + engineName)); +} + +export function createSampleViolation(location: CodeLocation, ruleName: string = 'someRule', engineName: string = 'pmd', fixes?: Fix[], suggestions?: Suggestion[]) { + return { rule: ruleName, engine: engineName, - message: 'This message is unimportant', + message: 'Some dummy violation message', severity: 3, locations: [ - { - file: uri.fsPath, - startLine: range.start.line + 1, // Violations are 1 based while ranges are 0 based, so adjusting for this - startColumn: range.start.character + 1, - endLine: range.end.line + 1, - endColumn: range.end.character + 1 - } + location ], primaryLocationIndex: 0, tags: [], - resources: [] - } - const diag: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(sampleViolation); - diag.code = ruleName; - diag.source = 'pmd via Code Analyzer'; - return diag; -} + resources: [], + fixes: fixes, + suggestions: suggestions + }; +} \ No newline at end of file From 47d3aa89433e4a7ead8beb996ec42b608c4aabd8 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:25:44 -0400 Subject: [PATCH 06/17] =?UTF-8?q?CHANGE:=20@W-19178465@:=20Update=20depend?= =?UTF-8?q?encies=20as=20much=20as=20possible=20and=20upd=E2=80=A6=20(#266?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 2539 ++++++++++++----- package.json | 20 +- src/extension.ts | 4 +- .../external-services/telemetry-service.ts | 6 +- src/test/legacy/test-utils.ts | 2 +- src/test/unit/stubs.ts | 4 +- 6 files changed, 1793 insertions(+), 782 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad8444fb..9b79e746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,17 @@ "version": "1.9.0", "license": "BSD-3-Clause", "dependencies": { - "@salesforce/vscode-service-provider": "^1.4.0", + "@salesforce/vscode-service-provider": "^1.5.0", "@types/jest": "^30.0.0", "@types/semver": "^7.7.0", "@types/tmp": "^0.2.6", "diff": "^5.2.0", "glob": "^11.0.3", "semver": "^7.7.2", - "tmp": "^0.2.3" + "tmp": "^0.2.4" }, "devDependencies": { - "@eslint/js": "^9.31.0", + "@eslint/js": "^9.32.0", "@types/chai": "^4.3.20", "@types/diff": "^5.2.3", "@types/mocha": "^10.0.10", @@ -30,19 +30,19 @@ "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", "chai": "^4.5.0", - "esbuild": "^0.25.6", - "eslint": "^9.31.0", - "jest": "^30.0.4", - "jest-mock-vscode": "^4.5.0", + "esbuild": "^0.25.8", + "eslint": "^9.32.0", + "jest": "^30.0.5", + "jest-mock-vscode": "^4.6.0", "mocha": "^10.8.2", "ovsx": "^0.10.5", "proxyquire": "^2.1.3", "rimraf": "*", "sinon": "^15.2.0", - "ts-jest": "^29.4.0", + "ts-jest": "^29.4.1", "ts-node": "^10.9.2", - "typescript": "^5.8.3", - "typescript-eslint": "^8.37.0" + "typescript": "^5.9.2", + "typescript-eslint": "^8.39.0" }, "engines": { "node": ">=20.9.0", @@ -175,9 +175,9 @@ } }, "node_modules/@azure/identity": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.10.2.tgz", - "integrity": "sha512-Uth4vz0j+fkXCkbvutChUj03PDCokjbC6Wk9JT8hHEUtpy/EurNKAseb3+gO6Zi9VYBvwt61pgbzn1ovk942Qg==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.11.1.tgz", + "integrity": "sha512-0ZdsLRaOyLxtCYgyuqyWqGU5XQ9gGnjxgfoNTt1pvELGkkUFrMATABZFIq8gusM7N1qbqpVtwLOhk0d/3kacLg==", "dev": true, "license": "MIT", "dependencies": { @@ -212,22 +212,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.15.0.tgz", - "integrity": "sha512-+AIGTvpVz+FIx5CsM1y+nW0r/qOb/ChRdM8/Cbp+jKWC0Wdw4ldnwPdYOBi5NaALUQnYITirD9XMZX7LdklEzQ==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.19.0.tgz", + "integrity": "sha512-g6Ea+sJmK7l5NUyrPhtD7DNj/tZcsr6VTNNLNuYs8yPvL3HNiIpO/0kzXntF9AqJ/6L+uz9aHmoT1x+RNq6zBQ==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.8.1" + "@azure/msal-common": "15.10.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.8.1.tgz", - "integrity": "sha512-ltIlFK5VxeJ5BurE25OsJIfcx1Q3H/IZg2LjV9d4vmH+5t4c1UCyRQ/HgKLgXuCZShs7qfc/TC95GYZfsUsJUQ==", + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.10.0.tgz", + "integrity": "sha512-+cGnma71NV3jzl6DdgdHsqriN4ZA7puBIzObSYCvcIVGMULGb2NrcOGV6IJxO06HoVRHFKijkxd9lcBvS063KQ==", "dev": true, "license": "MIT", "engines": { @@ -235,13 +235,13 @@ } }, "node_modules/@azure/msal-node": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.6.3.tgz", - "integrity": "sha512-95wjsKGyUcAd5tFmQBo5Ug/kOj+hFh/8FsXuxluEvdfbgg6xCimhSP9qnyq6+xIg78/jREkBD1/BSqd7NIDDYQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.7.0.tgz", + "integrity": "sha512-WsL11pT0hnoIr/4NCjG6uJswkmNA/9AgEre4mSQZS2e+ZPKUWwUdA5nCTnr4n1FMT1O5ezSEiJushnPW25Y+dA==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.8.1", + "@azure/msal-common": "15.10.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -457,14 +457,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" + "@babel/types": "^7.28.2" }, "engines": { "node": ">=6.9.0" @@ -725,16 +725,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -770,9 +760,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", - "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -814,10 +804,112 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", - "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -831,6 +923,363 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -1028,9 +1477,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", - "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", "dev": true, "license": "MIT", "engines": { @@ -1051,9 +1500,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1285,17 +1734,17 @@ } }, "node_modules/@jest/console": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.4.tgz", - "integrity": "sha512-tMLCDvBJBwPqMm4OAiuKm2uF5y5Qe26KgcMn+nrDSWpEW+eeFmqA0iO4zJfL16GP7gE3bUUQ3hIuUJ22AqVRnw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.5.tgz", + "integrity": "sha512-xY6b0XiL0Nav3ReresUarwl2oIz1gTnxGbGpho9/rbUWsLH0f1OD/VT84xs8c7VmH7MChnLb0pag6PhZhAdDiA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.0.2", - "jest-util": "30.0.2", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", "slash": "^3.0.0" }, "engines": { @@ -1303,39 +1752,39 @@ } }, "node_modules/@jest/core": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.4.tgz", - "integrity": "sha512-MWScSO9GuU5/HoWjpXAOBs6F/iobvK1XlioelgOM9St7S0Z5WTI9kjCQLPeo4eQRRYusyLW25/J7J5lbFkrYXw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.5.tgz", + "integrity": "sha512-fKD0OulvRsXF1hmaFgHhVJzczWzA1RXMMo9LTPuFXo9q/alDbME3JIyWYqovWsUBWSoBcsHaGPSLF9rz4l9Qeg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.0.4", + "@jest/console": "30.0.5", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.0.4", - "@jest/test-result": "30.0.4", - "@jest/transform": "30.0.4", - "@jest/types": "30.0.1", + "@jest/reporters": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.0.2", - "jest-config": "30.0.4", - "jest-haste-map": "30.0.2", - "jest-message-util": "30.0.2", + "jest-changed-files": "30.0.5", + "jest-config": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-message-util": "30.0.5", "jest-regex-util": "30.0.1", - "jest-resolve": "30.0.2", - "jest-resolve-dependencies": "30.0.4", - "jest-runner": "30.0.4", - "jest-runtime": "30.0.4", - "jest-snapshot": "30.0.4", - "jest-util": "30.0.2", - "jest-validate": "30.0.2", - "jest-watcher": "30.0.4", + "jest-resolve": "30.0.5", + "jest-resolve-dependencies": "30.0.5", + "jest-runner": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "jest-watcher": "30.0.5", "micromatch": "^4.0.8", - "pretty-format": "30.0.2", + "pretty-format": "30.0.5", "slash": "^3.0.0" }, "engines": { @@ -1360,39 +1809,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.4.tgz", - "integrity": "sha512-5NT+sr7ZOb8wW7C4r7wOKnRQ8zmRWQT2gW4j73IXAKp5/PX1Z8MCStBLQDYfIG3n1Sw0NRfYGdp0iIPVooBAFQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.0.4", - "@jest/types": "30.0.1", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", - "jest-mock": "30.0.2" + "jest-mock": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.4.tgz", - "integrity": "sha512-Z/DL7t67LBHSX4UzDyeYKqOxE/n7lbrrgEwWM3dGiH5Dgn35nk+YtgzKudmfIrBI8DRRrKYY5BCo3317HZV1Fw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-6udac8KKrtTtC+AXZ2iUN/R7dp7Ydry+Fo6FPFnDG54wjVMnb6vW/XNlf7Xj8UDjAE3aAVAsR4KFyKk3TCXmTA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.0.4", - "jest-snapshot": "30.0.4" + "expect": "30.0.5", + "jest-snapshot": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz", - "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.5.tgz", + "integrity": "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew==", "license": "MIT", "dependencies": { "@jest/get-type": "30.0.1" @@ -1402,18 +1851,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.4.tgz", - "integrity": "sha512-qZ7nxOcL5+gwBO6LErvwVy5k06VsX/deqo2XnVUSTV0TNC9lrg8FC3dARbi+5lmrr5VyX5drragK+xLcOjvjYw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "30.0.2", - "jest-mock": "30.0.2", - "jest-util": "30.0.2" + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1429,16 +1878,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.4.tgz", - "integrity": "sha512-avyZuxEHF2EUhFF6NEWVdxkRRV6iXXcIES66DLhuLlU7lXhtFG/ySq/a8SRZmEJSsLkNAFX6z6mm8KWyXe9OEA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.5.tgz", + "integrity": "sha512-7oEJT19WW4oe6HR7oLRvHxwlJk2gev0U9px3ufs8sX9PoD1Eza68KF0/tlN7X0dq/WVsBScXQGgCldA1V9Y/jA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.0.4", - "@jest/expect": "30.0.4", - "@jest/types": "30.0.1", - "jest-mock": "30.0.2" + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/types": "30.0.5", + "jest-mock": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1458,17 +1907,17 @@ } }, "node_modules/@jest/reporters": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.4.tgz", - "integrity": "sha512-6ycNmP0JSJEEys1FbIzHtjl9BP0tOZ/KN6iMeAKrdvGmUsa1qfRdlQRUDKJ4P84hJ3xHw1yTqJt4fvPNHhyE+g==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.5.tgz", + "integrity": "sha512-mafft7VBX4jzED1FwGC1o/9QUM2xebzavImZMeqnsklgcyxBto8mV4HzNSzUrryJ+8R9MFOM3HgYuDradWR+4g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.0.4", - "@jest/test-result": "30.0.4", - "@jest/transform": "30.0.4", - "@jest/types": "30.0.1", + "@jest/console": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -1481,9 +1930,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.0.2", - "jest-util": "30.0.2", - "jest-worker": "30.0.2", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1562,9 +2011,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", - "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.34.0" @@ -1574,13 +2023,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.4.tgz", - "integrity": "sha512-BEpX8M/Y5lG7MI3fmiO+xCnacOrVsnbqVrcDZIT8aSGkKV1w2WwvRQxSWw5SIS8ozg7+h8tSj5EO1Riqqxcdag==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.5.tgz", + "integrity": "sha512-XcCQ5qWHLvi29UUrowgDFvV4t7ETxX91CbDczMnoqXPOIcZOxyNdSjm6kV5XMc8+HkxfRegU/MUmnTbJRzGrUQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1605,14 +2054,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.4.tgz", - "integrity": "sha512-Mfpv8kjyKTHqsuu9YugB6z1gcdB3TSSOaKlehtVaiNlClMkEHY+5ZqCY2CrEE3ntpBMlstX/ShDAf84HKWsyIw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.5.tgz", + "integrity": "sha512-wPyztnK0gbDMQAJZ43tdMro+qblDHH1Ru/ylzUo21TBKqt88ZqnKKK2m30LKmLLoKtR2lxdpCC/P3g1vfKcawQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.0.4", - "@jest/types": "30.0.1", + "@jest/console": "30.0.5", + "@jest/types": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1621,15 +2070,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.4.tgz", - "integrity": "sha512-bj6ePmqi4uxAE8EHE0Slmk5uBYd9Vd/PcVt06CsBxzH4bbA8nGsI1YbXl/NH+eii4XRtyrRx+Cikub0x8H4vDg==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.5.tgz", + "integrity": "sha512-Aea/G1egWoIIozmDD7PBXUOxkekXl7ueGzrsGGi1SbeKgQqCYCIf+wfbflEbf2LiPxL8j2JZGLyrzZagjvW4YQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.0.4", + "@jest/test-result": "30.0.5", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.2", + "jest-haste-map": "30.0.5", "slash": "^3.0.0" }, "engines": { @@ -1637,23 +2086,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.4.tgz", - "integrity": "sha512-atvy4hRph/UxdCIBp+UB2jhEA/jJiUeGZ7QPgBi9jUUKNgi3WEoMXGNG7zbbELG2+88PMabUNCDchmqgJy3ELg==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz", + "integrity": "sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.0", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.2", + "jest-haste-map": "30.0.5", "jest-regex-util": "30.0.1", - "jest-util": "30.0.2", + "jest-util": "30.0.5", "micromatch": "^4.0.8", "pirates": "^4.0.7", "slash": "^3.0.0", @@ -1664,13 +2113,13 @@ } }, "node_modules/@jest/types": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", - "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.1", + "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -1715,53 +2164,287 @@ "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@node-rs/crc32": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.6.tgz", + "integrity": "sha512-+llXfqt+UzgoDzT9of5vPQPGqTAVCohU74I9zIBkNo5TH6s2P31DFJOGsJQKN207f0GHnYv5pV3wh3BCY/un/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/crc32-android-arm-eabi": "1.10.6", + "@node-rs/crc32-android-arm64": "1.10.6", + "@node-rs/crc32-darwin-arm64": "1.10.6", + "@node-rs/crc32-darwin-x64": "1.10.6", + "@node-rs/crc32-freebsd-x64": "1.10.6", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.6", + "@node-rs/crc32-linux-arm64-gnu": "1.10.6", + "@node-rs/crc32-linux-arm64-musl": "1.10.6", + "@node-rs/crc32-linux-x64-gnu": "1.10.6", + "@node-rs/crc32-linux-x64-musl": "1.10.6", + "@node-rs/crc32-wasm32-wasi": "1.10.6", + "@node-rs/crc32-win32-arm64-msvc": "1.10.6", + "@node-rs/crc32-win32-ia32-msvc": "1.10.6", + "@node-rs/crc32-win32-x64-msvc": "1.10.6" + } + }, + "node_modules/@node-rs/crc32-android-arm-eabi": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.6.tgz", + "integrity": "sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-android-arm64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.6.tgz", + "integrity": "sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-arm64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz", + "integrity": "sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-x64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.6.tgz", + "integrity": "sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-freebsd-x64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.6.tgz", + "integrity": "sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.6.tgz", + "integrity": "sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-gnu": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.6.tgz", + "integrity": "sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-musl": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.6.tgz", + "integrity": "sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-gnu": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.6.tgz", + "integrity": "sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-musl": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.6.tgz", + "integrity": "sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-wasm32-wasi": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.6.tgz", + "integrity": "sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/crc32-win32-arm64-msvc": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.6.tgz", + "integrity": "sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@node-rs/crc32": { + "node_modules/@node-rs/crc32-win32-ia32-msvc": { "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.6.tgz", - "integrity": "sha512-+llXfqt+UzgoDzT9of5vPQPGqTAVCohU74I9zIBkNo5TH6s2P31DFJOGsJQKN207f0GHnYv5pV3wh3BCY/un/A==", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.6.tgz", + "integrity": "sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@node-rs/crc32-android-arm-eabi": "1.10.6", - "@node-rs/crc32-android-arm64": "1.10.6", - "@node-rs/crc32-darwin-arm64": "1.10.6", - "@node-rs/crc32-darwin-x64": "1.10.6", - "@node-rs/crc32-freebsd-x64": "1.10.6", - "@node-rs/crc32-linux-arm-gnueabihf": "1.10.6", - "@node-rs/crc32-linux-arm64-gnu": "1.10.6", - "@node-rs/crc32-linux-arm64-musl": "1.10.6", - "@node-rs/crc32-linux-x64-gnu": "1.10.6", - "@node-rs/crc32-linux-x64-musl": "1.10.6", - "@node-rs/crc32-wasm32-wasi": "1.10.6", - "@node-rs/crc32-win32-arm64-msvc": "1.10.6", - "@node-rs/crc32-win32-ia32-msvc": "1.10.6", - "@node-rs/crc32-win32-x64-msvc": "1.10.6" } }, - "node_modules/@node-rs/crc32-darwin-arm64": { + "node_modules/@node-rs/crc32-win32-x64-msvc": { "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz", - "integrity": "sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.6.tgz", + "integrity": "sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 10" @@ -1817,9 +2500,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", - "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true, "license": "MIT", "engines": { @@ -1830,37 +2513,37 @@ } }, "node_modules/@salesforce/vscode-service-provider": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@salesforce/vscode-service-provider/-/vscode-service-provider-1.4.0.tgz", - "integrity": "sha512-N7732FGs12dhZfi+LKubfbqwYKMLNtbLbeQstYYkS00WWWoggC0ysm1o2rk3/iTOxKcz3CGBMwgJDYV9tzI9vg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@salesforce/vscode-service-provider/-/vscode-service-provider-1.5.0.tgz", + "integrity": "sha512-x/KO689KwVTMWrbTuKGyguoZ2Eb6ALHZM9FFftL94PJ96bL+fFmHOQEgm9ya8WakN9Q2tboJOh79Z9G1EhkhNw==", "license": "BSD-3-Clause", "engines": { "node": ">=18.18.2" } }, "node_modules/@secretlint/config-creator": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.0.tgz", - "integrity": "sha512-KW0aNs45F480TXy8NfqAHeB9vq0vHmU2lzGzXXul6vSqshWkZD0ArAyww/yj8Wq9Y3TEI1JinxNO4G+RWWvKdg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "^10.2.0" + "@secretlint/types": "^10.2.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@secretlint/config-loader": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.0.tgz", - "integrity": "sha512-Mmi3/GVg2wIS4VuBiYdV7eOLD+bV7IbwHHka8fBh2N/ODeQmulPfeIgmbDzcpBWxHFQPYZBN0mLYEC5iSj9f7g==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "^10.2.0", - "@secretlint/resolver": "^10.2.0", - "@secretlint/types": "^10.2.0", + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", "ajv": "^8.17.1", "debug": "^4.4.1", "rc-config-loader": "^4.1.3" @@ -1870,14 +2553,14 @@ } }, "node_modules/@secretlint/core": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.0.tgz", - "integrity": "sha512-7yIk6wSP4AGsgqzGZm5v4hW3Tr/wXAth8Ax3D6ikPvv5oCNTj/3Dgq6JdaLOQa2sUJbyQrYcLCONtmwEdiQzxw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "^10.2.0", - "@secretlint/types": "^10.2.0", + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" }, @@ -1886,17 +2569,17 @@ } }, "node_modules/@secretlint/formatter": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.0.tgz", - "integrity": "sha512-0pu7QA+ebVzJS/sSf0JWMx0QwgiZnYRHxWjRaSsYkUCqY/MZeMn+TAs0jiSDCci23OcmRcNNrrpkjm6N/hIXcg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "^10.2.0", - "@secretlint/types": "^10.2.0", - "@textlint/linter-formatter": "^15.1.0", - "@textlint/module-interop": "^15.1.0", - "@textlint/types": "^15.1.0", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", "chalk": "^5.4.1", "debug": "^4.4.1", "pluralize": "^8.0.0", @@ -1909,9 +2592,9 @@ } }, "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", "dev": true, "license": "MIT", "engines": { @@ -1922,18 +2605,18 @@ } }, "node_modules/@secretlint/node": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.0.tgz", - "integrity": "sha512-B8acPnY5xNBfdOl5PrsG9Z+7vujhMHWx1pJChrCUIDo3HvRu3IM2SfFUt6TAmLzr7jz12BP55/xJa5ebzBXWHg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "^10.2.0", - "@secretlint/core": "^10.2.0", - "@secretlint/formatter": "^10.2.0", - "@secretlint/profiler": "^10.2.0", - "@secretlint/source-creator": "^10.2.0", - "@secretlint/types": "^10.2.0", + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "p-map": "^7.0.3" }, @@ -1942,23 +2625,23 @@ } }, "node_modules/@secretlint/profiler": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.0.tgz", - "integrity": "sha512-Om/0m84ApSTTPWdm/tUCL4rTQ1D+s5XFDz8Ew+kPMScHedBsrM+dZQNRHj67y7CW+YmrgE8n4zFCYtvjQHAf4Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.0.tgz", - "integrity": "sha512-0CQvCkMCtDo8sgASJHlE02YigCgWK7DYR2cSM1PW9rA01jnlV4zWb3skTfgUeZw0F6Ie3c/eQMriEYe0SiWxJw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/secretlint-formatter-sarif": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.0.tgz", - "integrity": "sha512-y1jIHG5VXHn8lywSUm9YhsuqIYHbQJNx6UZFWyAFAUUE9Isg1sto7NDSnlzY2JWsVG8B1xOzv2uEnDegZvL7qw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1966,22 +2649,22 @@ } }, "node_modules/@secretlint/secretlint-rule-no-dotenv": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.0.tgz", - "integrity": "sha512-9hGk5e+Zxvo6SAIQglGk63tQ5Dn+IIfkEsuGLIh0gZDMu/PudKl/LeTC4fM3+lJLEA73QoVv4HJ057PRD1XSHw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "^10.2.0" + "@secretlint/types": "^10.2.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.0.tgz", - "integrity": "sha512-gRe3I7r5VQgwmG6HO8r3e0PVEl2cSmCqxzvThBLNGUehB0w1zMsav6emoYAIsfsZU29OukZ5hnJPzXH6sth1qQ==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", "dev": true, "license": "MIT", "engines": { @@ -1989,13 +2672,13 @@ } }, "node_modules/@secretlint/source-creator": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.0.tgz", - "integrity": "sha512-BwHt5TiAx3aAfeLAd27LV9JbEIf33Wi1stke2x/V/1GpHPvyxcgCljTh2hm+Mib7oZQaU8Esj8Jkp4zlWPsgOA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "^10.2.0", + "@secretlint/types": "^10.2.2", "istextorbinary": "^9.5.0" }, "engines": { @@ -2003,9 +2686,9 @@ } }, "node_modules/@secretlint/types": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.0.tgz", - "integrity": "sha512-8fHvsBMQtibVDxHKCyjaxDdWStE6E063xwBqrBz1zl/VArzEVUzXF+NLNc/LdIuyVrgQ41BG7Bmvo5bbZQ+XEg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", "dev": true, "license": "MIT", "engines": { @@ -2013,9 +2696,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.37", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", - "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", "license": "MIT" }, "node_modules/@sindresorhus/merge-streams": { @@ -2062,14 +2745,13 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -2081,24 +2763,24 @@ "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@textlint/ast-node-types": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.0.tgz", - "integrity": "sha512-nr9wEiZCNYafGZ++uWFZgPlDX3Bi7u4T2d5swpaoMvc1G2toXsBfe7UNVwXZq5dvYDbQN7vDeb3ltlKQ8JnPNQ==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.1.tgz", + "integrity": "sha512-20fEcLPsXg81yWpApv4FQxrZmlFF/Ta7/kz1HGIL+pJo5cSTmkc+eCki3GpOPZIoZk0tbJU8hrlwUb91F+3SNQ==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.2.0.tgz", - "integrity": "sha512-L+fM2OTs17hRxPCLKUdPjHce7cJp81gV9ku53FCL+cXnq5bZx0XYYkqKdtC0jnXujkQmrTYU3SYFrb4DgXqbtA==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.2.1.tgz", + "integrity": "sha512-oollG/BHa07+mMt372amxHohteASC+Zxgollc1sZgiyxo4S6EuureV3a4QIQB0NecA+Ak3d0cl0WI/8nou38jw==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.2.0", - "@textlint/resolver": "15.2.0", - "@textlint/types": "15.2.0", + "@textlint/module-interop": "15.2.1", + "@textlint/resolver": "15.2.1", + "@textlint/types": "15.2.1", "chalk": "^4.1.2", "debug": "^4.4.1", "js-yaml": "^3.14.1", @@ -2141,27 +2823,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.2.0.tgz", - "integrity": "sha512-M3y1s2dZZH8PSHo4RUlnPOdK3qN90wmYGaEdy+il9/BQfrrift7S9R8lOfhHoPS0m9FEsnwyj3dQLkCUugPd9Q==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.2.1.tgz", + "integrity": "sha512-b/C/ZNrm05n1ypymDknIcpkBle30V2ZgE3JVqQlA9PnQV46Ky510qrZk6s9yfKgA3m1YRnAw04m8xdVtqjq1qg==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.2.0.tgz", - "integrity": "sha512-1UC+5bEtuoht7uu0uGofb7sX7j17Mvyst9InrRtI4XgKhh1uMZz5YFiMYpNwry1GgCZvq7Wyq1fqtEIsvYWqFw==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.2.1.tgz", + "integrity": "sha512-FY3aK4tElEcOJVUsaMj4Zro4jCtKEEwUMIkDL0tcn6ljNcgOF7Em+KskRRk/xowFWayqDtdz5T3u7w/6fjjuJQ==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.2.0.tgz", - "integrity": "sha512-wpF+xjGJgJK2JiwUdYjuNZrbuas3KfC9VDnHKac6aBLFyrI1iXuXtuxKXQDFi5/hebACactSJOuVVbuQbdJZ1Q==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.2.1.tgz", + "integrity": "sha512-zyqNhSatK1cwxDUgosEEN43hFh3WCty9Zm2Vm3ogU566IYegifwqN54ey/CiRy/DiO4vMcFHykuQnh2Zwp6LLw==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.2.0" + "@textlint/ast-node-types": "15.2.1" } }, "node_modules/@tsconfig/node10": { @@ -2192,6 +2874,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2228,13 +2921,13 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/chai": { @@ -2307,9 +3000,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.16.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.4.tgz", - "integrity": "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g==", + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2387,17 +3080,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", - "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", + "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.37.0", - "@typescript-eslint/type-utils": "8.37.0", - "@typescript-eslint/utils": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/type-utils": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2411,9 +3104,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.37.0", + "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -2427,16 +3120,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz", - "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", + "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.37.0", - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "engines": { @@ -2448,18 +3141,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz", - "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", + "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.37.0", - "@typescript-eslint/types": "^8.37.0", + "@typescript-eslint/tsconfig-utils": "^8.39.0", + "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "engines": { @@ -2470,18 +3163,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz", - "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", + "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0" + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2492,9 +3185,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz", - "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", + "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", "dev": true, "license": "MIT", "engines": { @@ -2505,19 +3198,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz", - "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", + "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0", - "@typescript-eslint/utils": "8.37.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2530,13 +3223,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz", - "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", + "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", "dev": true, "license": "MIT", "engines": { @@ -2548,16 +3241,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz", - "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", + "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.37.0", - "@typescript-eslint/tsconfig-utils": "8.37.0", - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0", + "@typescript-eslint/project-service": "8.39.0", + "@typescript-eslint/tsconfig-utils": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2573,85 +3266,340 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz", - "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", + "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", + "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz", + "integrity": "sha512-sOx1PKSuFwnIl7z4RN0Ls7N9AQawmR9r66eI5rFCzLDIs8HTIYrIpH9QjYWoX0lkgGrkLxXhi4QnK7MizPRrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.37.0", - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz", - "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==", + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@typescript-eslint/types": "8.37.0", - "eslint-visitor-keys": "^4.2.1" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=14.0.0" } }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz", - "integrity": "sha512-sOx1PKSuFwnIl7z4RN0Ls7N9AQawmR9r66eI5rFCzLDIs8HTIYrIpH9QjYWoX0lkgGrkLxXhi4QnK7MizPRrIg==", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ] }, "node_modules/@vscode/test-cli": { @@ -2941,6 +3889,34 @@ "@vscode/vsce-sign-win32-x64": "2.0.5" } }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.5.tgz", + "integrity": "sha512-XVmnF40APwRPXSLYA28Ye+qWxB25KhSVpF2eZVtVOs6g7fkpOxsVnpRU1Bz2xG4ySI79IRuapDJoAQFkoOgfdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.5.tgz", + "integrity": "sha512-JuxY3xcquRsOezKq6PEHwCgd1rh1GnhyH6urVEWUzWn1c1PC4EOoyffMD+zLZtFuZF5qR1I0+cqDRNKyPvpK7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, "node_modules/@vscode/vsce-sign-darwin-arm64": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.5.tgz", @@ -2955,6 +3931,90 @@ "darwin" ] }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.5.tgz", + "integrity": "sha512-ma9JDC7FJ16SuPXlLKkvOD2qLsmW/cKfqK4zzM2iJE1PbckF3BlR08lYqHV89gmuoTpYB55+z8Y5Fz4wEJBVDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.5.tgz", + "integrity": "sha512-cdCwtLGmvC1QVrkIsyzv01+o9eR+wodMJUZ9Ak3owhcGxPRB53/WvrDHAFYA6i8Oy232nuen1YqWeEohqBuSzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.5.tgz", + "integrity": "sha512-Hr1o0veBymg9SmkCqYnfaiUnes5YK6k/lKFA5MhNmiEN5fNqxyPUCdRZMFs3Ajtx2OFW4q3KuYVRwGA7jdLo7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.5.tgz", + "integrity": "sha512-XLT0gfGMcxk6CMRLDkgqEPTyG8Oa0OFe1tPv2RVbphSOjFWJwZgK3TYWx39i/7gqpDHlax0AP6cgMygNJrA6zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.5.tgz", + "integrity": "sha512-hco8eaoTcvtmuPhavyCZhrk5QIcLiyAUhEso87ApAWDllG7djIrWiOCtqn48k4pHz+L8oCQlE0nwNHfcYcxOPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.5.tgz", + "integrity": "sha512-1ixKFGM2FwM+6kQS2ojfY3aAelICxjiCzeg4nTHpkeU1Tfs4RC+lVLrgq5NwcBC7ZLr6UfY3Ct3D6suPeOf7BQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vscode/vsce/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3146,13 +4206,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3172,13 +4225,13 @@ } }, "node_modules/babel-jest": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.4.tgz", - "integrity": "sha512-UjG2j7sAOqsp2Xua1mS/e+ekddkSu3wpf4nZUSvXNHuVWdaOUXQ77+uyjJLDE9i0atm5x4kds8K9yb5lRsRtcA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz", + "integrity": "sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.0.4", + "@jest/transform": "30.0.5", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.0", "babel-preset-jest": "30.0.1", @@ -3226,9 +4279,9 @@ } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", - "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -3249,7 +4302,7 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "node_modules/babel-preset-jest": { @@ -3599,9 +4652,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "dev": true, "funding": [ { @@ -3690,9 +4743,9 @@ } }, "node_modules/cheerio": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", - "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", "dev": true, "license": "MIT", "dependencies": { @@ -3700,16 +4753,16 @@ "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.0", + "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^7.10.0", + "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">=18.17" + "node": ">=20.18.1" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -4343,26 +5396,10 @@ "url": "https://bevry.me/fund" } }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { - "version": "1.5.183", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz", - "integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==", + "version": "1.5.197", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.197.tgz", + "integrity": "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==", "dev": true, "license": "ISC" }, @@ -4411,9 +5448,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -4510,9 +5547,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", - "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4523,32 +5560,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.6", - "@esbuild/android-arm": "0.25.6", - "@esbuild/android-arm64": "0.25.6", - "@esbuild/android-x64": "0.25.6", - "@esbuild/darwin-arm64": "0.25.6", - "@esbuild/darwin-x64": "0.25.6", - "@esbuild/freebsd-arm64": "0.25.6", - "@esbuild/freebsd-x64": "0.25.6", - "@esbuild/linux-arm": "0.25.6", - "@esbuild/linux-arm64": "0.25.6", - "@esbuild/linux-ia32": "0.25.6", - "@esbuild/linux-loong64": "0.25.6", - "@esbuild/linux-mips64el": "0.25.6", - "@esbuild/linux-ppc64": "0.25.6", - "@esbuild/linux-riscv64": "0.25.6", - "@esbuild/linux-s390x": "0.25.6", - "@esbuild/linux-x64": "0.25.6", - "@esbuild/netbsd-arm64": "0.25.6", - "@esbuild/netbsd-x64": "0.25.6", - "@esbuild/openbsd-arm64": "0.25.6", - "@esbuild/openbsd-x64": "0.25.6", - "@esbuild/openharmony-arm64": "0.25.6", - "@esbuild/sunos-x64": "0.25.6", - "@esbuild/win32-arm64": "0.25.6", - "@esbuild/win32-ia32": "0.25.6", - "@esbuild/win32-x64": "0.25.6" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -4575,9 +5612,9 @@ } }, "node_modules/eslint": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", - "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", + "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", "dependencies": { @@ -4587,8 +5624,8 @@ "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.31.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.32.0", + "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4857,17 +5894,17 @@ } }, "node_modules/expect": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz", - "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==", "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.0.4", + "@jest/expect-utils": "30.0.5", "@jest/get-type": "30.0.1", - "jest-matcher-utils": "30.0.4", - "jest-message-util": "30.0.2", - "jest-mock": "30.0.2", - "jest-util": "30.0.2" + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4971,29 +6008,6 @@ "node": ">=16.0.0" } }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-keys": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", @@ -5069,9 +6083,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true, "funding": [ { @@ -5106,9 +6120,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -5131,9 +6145,9 @@ "optional": true }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "dev": true, "license": "MIT", "dependencies": { @@ -5441,6 +6455,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5892,13 +6928,12 @@ } }, "node_modules/is-it-type": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.2.tgz", - "integrity": "sha512-q/gOZQTNYABAxaXWnBKZjTFH4yACvWEFtgVOj+LbgxYIgAJG1xVmUZOsECSrZPIemYUQvaQWVilSFVbh4Eyt8A==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", + "integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.16.7", "globalthis": "^1.0.2" }, "engines": { @@ -6106,60 +7141,17 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jake/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/jest": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.4.tgz", - "integrity": "sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz", + "integrity": "sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.0.4", - "@jest/types": "30.0.1", + "@jest/core": "30.0.5", + "@jest/types": "30.0.5", "import-local": "^3.2.0", - "jest-cli": "30.0.4" + "jest-cli": "30.0.5" }, "bin": { "jest": "bin/jest.js" @@ -6177,14 +7169,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.2.tgz", - "integrity": "sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.5.tgz", + "integrity": "sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.0.2", + "jest-util": "30.0.5", "p-limit": "^3.1.0" }, "engines": { @@ -6192,29 +7184,29 @@ } }, "node_modules/jest-circus": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.4.tgz", - "integrity": "sha512-o6UNVfbXbmzjYgmVPtSQrr5xFZCtkDZGdTlptYvGFSN80RuOOlTe73djvMrs+QAuSERZWcHBNIOMH+OEqvjWuw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.5.tgz", + "integrity": "sha512-h/sjXEs4GS+NFFfqBDYT7y5Msfxh04EwWLhQi0F8kuWpe+J/7tICSlswU8qvBqumR3kFgHbfu7vU6qruWWBPug==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.0.4", - "@jest/expect": "30.0.4", - "@jest/test-result": "30.0.4", - "@jest/types": "30.0.1", + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.0.2", - "jest-matcher-utils": "30.0.4", - "jest-message-util": "30.0.2", - "jest-runtime": "30.0.4", - "jest-snapshot": "30.0.4", - "jest-util": "30.0.2", + "jest-each": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", "p-limit": "^3.1.0", - "pretty-format": "30.0.2", + "pretty-format": "30.0.5", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -6224,21 +7216,21 @@ } }, "node_modules/jest-cli": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.4.tgz", - "integrity": "sha512-3dOrP3zqCWBkjoVG1zjYJpD9143N9GUCbwaF2pFF5brnIgRLHmKcCIw+83BvF1LxggfMWBA0gxkn6RuQVuRhIQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.5.tgz", + "integrity": "sha512-Sa45PGMkBZzF94HMrlX4kUyPOwUpdZasaliKN3mifvDmkhLYqLLg8HQTzn6gq7vJGahFYMQjXgyJWfYImKZzOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.0.4", - "@jest/test-result": "30.0.4", - "@jest/types": "30.0.1", + "@jest/core": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.0.4", - "jest-util": "30.0.2", - "jest-validate": "30.0.2", + "jest-config": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", "yargs": "^17.7.2" }, "bin": { @@ -6257,34 +7249,34 @@ } }, "node_modules/jest-config": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.4.tgz", - "integrity": "sha512-3dzbO6sh34thAGEjJIW0fgT0GA0EVlkski6ZzMcbW6dzhenylXAE/Mj2MI4HonroWbkKc6wU6bLVQ8dvBSZ9lA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.5.tgz", + "integrity": "sha512-aIVh+JNOOpzUgzUnPn5FLtyVnqc3TQHVMupYtyeURSb//iLColiMIR8TxCIDKyx9ZgjKnXGucuW68hCxgbrwmA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.0.1", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.0.4", - "@jest/types": "30.0.1", - "babel-jest": "30.0.4", + "@jest/test-sequencer": "30.0.5", + "@jest/types": "30.0.5", + "babel-jest": "30.0.5", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-circus": "30.0.4", + "jest-circus": "30.0.5", "jest-docblock": "30.0.1", - "jest-environment-node": "30.0.4", + "jest-environment-node": "30.0.5", "jest-regex-util": "30.0.1", - "jest-resolve": "30.0.2", - "jest-runner": "30.0.4", - "jest-util": "30.0.2", - "jest-validate": "30.0.2", + "jest-resolve": "30.0.5", + "jest-runner": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "30.0.2", + "pretty-format": "30.0.5", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -6370,15 +7362,15 @@ } }, "node_modules/jest-diff": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz", - "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", "license": "MIT", "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.0.1", "chalk": "^4.1.2", - "pretty-format": "30.0.2" + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6398,56 +7390,56 @@ } }, "node_modules/jest-each": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.2.tgz", - "integrity": "sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.5.tgz", + "integrity": "sha512-dKjRsx1uZ96TVyejD3/aAWcNKy6ajMaN531CwWIsrazIqIoXI9TnnpPlkrEYku/8rkS3dh2rbH+kMOyiEIv0xQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.0.1", - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "chalk": "^4.1.2", - "jest-util": "30.0.2", - "pretty-format": "30.0.2" + "jest-util": "30.0.5", + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.4.tgz", - "integrity": "sha512-p+rLEzC2eThXqiNh9GHHTC0OW5Ca4ZfcURp7scPjYBcmgpR9HG6750716GuUipYf2AcThU3k20B31USuiaaIEg==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.5.tgz", + "integrity": "sha512-ppYizXdLMSvciGsRsMEnv/5EFpvOdXBaXRBzFUDPWrsfmog4kYrOGWXarLllz6AXan6ZAA/kYokgDWuos1IKDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.0.4", - "@jest/fake-timers": "30.0.4", - "@jest/types": "30.0.1", + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", - "jest-mock": "30.0.2", - "jest-util": "30.0.2", - "jest-validate": "30.0.2" + "jest-mock": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.2.tgz", - "integrity": "sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.5.tgz", + "integrity": "sha512-dkmlWNlsTSR0nH3nRfW5BKbqHefLZv0/6LCccG0xFCTWcJu8TuEwG+5Cm75iBfjVoockmO6J35o5gxtFSn5xeg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.0.2", - "jest-worker": "30.0.2", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", "micromatch": "^4.0.8", "walker": "^1.0.8" }, @@ -6459,47 +7451,47 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.2.tgz", - "integrity": "sha512-U66sRrAYdALq+2qtKffBLDWsQ/XoNNs2Lcr83sc9lvE/hEpNafJlq2lXCPUBMNqamMECNxSIekLfe69qg4KMIQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.5.tgz", + "integrity": "sha512-3Uxr5uP8jmHMcsOtYMRB/zf1gXN3yUIc+iPorhNETG54gErFIiUhLvyY/OggYpSMOEYqsmRxmuU4ZOoX5jpRFg==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.0.1", - "pretty-format": "30.0.2" + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz", - "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.5.tgz", + "integrity": "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==", "license": "MIT", "dependencies": { "@jest/get-type": "30.0.1", "chalk": "^4.1.2", - "jest-diff": "30.0.4", - "pretty-format": "30.0.2" + "jest-diff": "30.0.5", + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", - "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", - "pretty-format": "30.0.2", + "pretty-format": "30.0.5", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -6508,23 +7500,23 @@ } }, "node_modules/jest-mock": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz", - "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "license": "MIT", "dependencies": { - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@types/node": "*", - "jest-util": "30.0.2" + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock-vscode": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/jest-mock-vscode/-/jest-mock-vscode-4.5.0.tgz", - "integrity": "sha512-SHgNkoanGSeL3s6VZLGfR2/S+k9p+MXLFfdSN08x8ihH0iFB8PRVktfGyDXgzQN+Ov9mgsDmm451GUnAXIA9Cg==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/jest-mock-vscode/-/jest-mock-vscode-4.6.0.tgz", + "integrity": "sha512-FTEGP5TT5y/NfoiCJQQOcauLFnA7wIcbedjlZbLZ+Udlm+QwoKcF1E+Qv5TiYTLRmV8jsPQf6RpzvLQk1iYDaw==", "dev": true, "license": "MIT", "dependencies": { @@ -6565,18 +7557,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.2.tgz", - "integrity": "sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz", + "integrity": "sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.2", + "jest-haste-map": "30.0.5", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.0.2", - "jest-validate": "30.0.2", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -6585,46 +7577,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.4.tgz", - "integrity": "sha512-EQBYow19B/hKr4gUTn+l8Z+YLlP2X0IoPyp0UydOtrcPbIOYzJ8LKdFd+yrbwztPQvmlBFUwGPPEzHH1bAvFAw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.5.tgz", + "integrity": "sha512-/xMvBR4MpwkrHW4ikZIWRttBBRZgWK4d6xt3xW1iRDSKt4tXzYkMkyPfBnSCgv96cpkrctfXs6gexeqMYqdEpw==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.0.4" + "jest-snapshot": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.4.tgz", - "integrity": "sha512-mxY0vTAEsowJwvFJo5pVivbCpuu6dgdXRmt3v3MXjBxFly7/lTk3Td0PaMyGOeNQUFmSuGEsGYqhbn7PA9OekQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.5.tgz", + "integrity": "sha512-JcCOucZmgp+YuGgLAXHNy7ualBx4wYSgJVWrYMRBnb79j9PD0Jxh0EHvR5Cx/r0Ce+ZBC4hCdz2AzFFLl9hCiw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.0.4", - "@jest/environment": "30.0.4", - "@jest/test-result": "30.0.4", - "@jest/transform": "30.0.4", - "@jest/types": "30.0.1", + "@jest/console": "30.0.5", + "@jest/environment": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.0.1", - "jest-environment-node": "30.0.4", - "jest-haste-map": "30.0.2", - "jest-leak-detector": "30.0.2", - "jest-message-util": "30.0.2", - "jest-resolve": "30.0.2", - "jest-runtime": "30.0.4", - "jest-util": "30.0.2", - "jest-watcher": "30.0.4", - "jest-worker": "30.0.2", + "jest-environment-node": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-leak-detector": "30.0.5", + "jest-message-util": "30.0.5", + "jest-resolve": "30.0.5", + "jest-runtime": "30.0.5", + "jest-util": "30.0.5", + "jest-watcher": "30.0.5", + "jest-worker": "30.0.5", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -6633,32 +7625,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.4.tgz", - "integrity": "sha512-tUQrZ8+IzoZYIHoPDQEB4jZoPyzBjLjq7sk0KVyd5UPRjRDOsN7o6UlvaGF8ddpGsjznl9PW+KRgWqCNO+Hn7w==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.5.tgz", + "integrity": "sha512-7oySNDkqpe4xpX5PPiJTe5vEa+Ak/NnNz2bGYZrA1ftG3RL3EFlHaUkA1Cjx+R8IhK0Vg43RML5mJedGTPNz3A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.0.4", - "@jest/fake-timers": "30.0.4", - "@jest/globals": "30.0.4", + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/globals": "30.0.5", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.0.4", - "@jest/transform": "30.0.4", - "@jest/types": "30.0.1", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.2", - "jest-message-util": "30.0.2", - "jest-mock": "30.0.2", + "jest-haste-map": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", "jest-regex-util": "30.0.1", - "jest-resolve": "30.0.2", - "jest-snapshot": "30.0.4", - "jest-util": "30.0.2", + "jest-resolve": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -6728,9 +7720,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.4.tgz", - "integrity": "sha512-S/8hmSkeUib8WRUq9pWEb5zMfsOjiYWDWzFzKnjX7eDyKKgimsu9hcmsUEg8a7dPAw8s/FacxsXquq71pDgPjQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.5.tgz", + "integrity": "sha512-T00dWU/Ek3LqTp4+DcW6PraVxjk28WY5Ua/s+3zUKSERZSNyxTqhDXCWKG5p2HAJ+crVQ3WJ2P9YVHpj1tkW+g==", "dev": true, "license": "MIT", "dependencies": { @@ -6739,20 +7731,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.0.4", + "@jest/expect-utils": "30.0.5", "@jest/get-type": "30.0.1", - "@jest/snapshot-utils": "30.0.4", - "@jest/transform": "30.0.4", - "@jest/types": "30.0.1", + "@jest/snapshot-utils": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", "babel-preset-current-node-syntax": "^1.1.0", "chalk": "^4.1.2", - "expect": "30.0.4", + "expect": "30.0.5", "graceful-fs": "^4.2.11", - "jest-diff": "30.0.4", - "jest-matcher-utils": "30.0.4", - "jest-message-util": "30.0.2", - "jest-util": "30.0.2", - "pretty-format": "30.0.2", + "jest-diff": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -6761,12 +7753,12 @@ } }, "node_modules/jest-util": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", - "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -6778,9 +7770,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -6790,18 +7782,18 @@ } }, "node_modules/jest-validate": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.2.tgz", - "integrity": "sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.5.tgz", + "integrity": "sha512-ouTm6VFHaS2boyl+k4u+Qip4TSH7Uld5tyD8psQ8abGgt2uYYB8VwVfAHWHjHc0NWmGGbwO5h0sCPOGHHevefw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.0.1", - "@jest/types": "30.0.1", + "@jest/types": "30.0.5", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.0.2" + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6821,19 +7813,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.4.tgz", - "integrity": "sha512-YESbdHDs7aQOCSSKffG8jXqOKFqw4q4YqR+wHYpR5GWEQioGvL0BfbcjvKIvPEM0XGfsfJrka7jJz3Cc3gI4VQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.5.tgz", + "integrity": "sha512-z9slj/0vOwBDBjN3L4z4ZYaA+pG56d6p3kTUhFRYGvXbXMWhXmb/FIxREZCD06DYUwDKKnj2T80+Pb71CQ0KEg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.0.4", - "@jest/types": "30.0.1", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.0.2", + "jest-util": "30.0.5", "string-length": "^4.0.2" }, "engines": { @@ -6841,15 +7833,15 @@ } }, "node_modules/jest-worker": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.2.tgz", - "integrity": "sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.5.tgz", + "integrity": "sha512-ojRXsWzEP16NdUuBw/4H/zkZdHOa7MMYCk4E430l+8fELeLg/mqmMlRhjL7UNZvQrDmnovWZV4DxX03fZF48fQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.0.2", + "jest-util": "30.0.5", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -7130,14 +8122,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7458,7 +8442,6 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7698,9 +8681,9 @@ "optional": true }, "node_modules/napi-postinstall": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.0.tgz", - "integrity": "sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", + "integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==", "dev": true, "license": "MIT", "bin": { @@ -7720,6 +8703,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/nise": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", @@ -7976,9 +8966,9 @@ } }, "node_modules/ora/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", "dev": true, "license": "MIT", "engines": { @@ -8506,12 +9496,12 @@ } }, "node_modules/pretty-format": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", - "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.1", + "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" }, @@ -9037,16 +10027,16 @@ "license": "ISC" }, "node_modules/secretlint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.0.tgz", - "integrity": "sha512-JxbGUpsa8OYeF9LsMKxyHbBMrojTIF+p6R7BHxbOSiMgD9Qct0Rlh3flkEZ3EeL/hQvANGSbL+EY7zyrxdY1EQ==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-creator": "^10.2.0", - "@secretlint/formatter": "^10.2.0", - "@secretlint/node": "^10.2.0", - "@secretlint/profiler": "^10.2.0", + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", "debug": "^4.4.1", "globby": "^14.1.0", "read-pkg": "^9.0.1" @@ -9376,9 +10366,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", "dev": true, "license": "CC0-1.0" }, @@ -9685,13 +10675,13 @@ } }, "node_modules/synckit": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", - "integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==", + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.4" + "@pkgr/core": "^0.2.9" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -9916,9 +10906,9 @@ } }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", + "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", "license": "MIT", "engines": { "node": ">=14.14" @@ -9957,15 +10947,15 @@ } }, "node_modules/ts-jest": { - "version": "29.4.0", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", - "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", + "version": "29.4.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", + "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", @@ -10156,9 +11146,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10170,16 +11160,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz", - "integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.0.tgz", + "integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.37.0", - "@typescript-eslint/parser": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0", - "@typescript-eslint/utils": "8.37.0" + "@typescript-eslint/eslint-plugin": "8.39.0", + "@typescript-eslint/parser": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10190,7 +11180,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/uc.micro": { @@ -10200,6 +11190,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/underscore": { "version": "1.13.7", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", @@ -10208,9 +11212,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", - "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.13.0.tgz", + "integrity": "sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==", "dev": true, "license": "MIT", "engines": { @@ -10457,6 +11461,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", diff --git a/package.json b/package.json index 46e6a97f..7b95d77f 100644 --- a/package.json +++ b/package.json @@ -33,17 +33,17 @@ "SFCA" ], "dependencies": { - "@salesforce/vscode-service-provider": "^1.4.0", + "@salesforce/vscode-service-provider": "^1.5.0", "@types/jest": "^30.0.0", "@types/semver": "^7.7.0", "@types/tmp": "^0.2.6", "diff": "^5.2.0", "glob": "^11.0.3", "semver": "^7.7.2", - "tmp": "^0.2.3" + "tmp": "^0.2.4" }, "devDependencies": { - "@eslint/js": "^9.31.0", + "@eslint/js": "^9.32.0", "@types/diff": "^5.2.3", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", @@ -54,19 +54,19 @@ "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", "chai": "^4.5.0", - "esbuild": "^0.25.6", - "eslint": "^9.31.0", - "jest": "^30.0.4", - "jest-mock-vscode": "^4.5.0", + "esbuild": "^0.25.8", + "eslint": "^9.32.0", + "jest": "^30.0.5", + "jest-mock-vscode": "^4.6.0", "mocha": "^10.8.2", "ovsx": "^0.10.5", "proxyquire": "^2.1.3", "rimraf": "*", "sinon": "^15.2.0", - "ts-jest": "^29.4.0", + "ts-jest": "^29.4.1", "ts-node": "^10.9.2", - "typescript": "^5.8.3", - "typescript-eslint": "^8.37.0" + "typescript": "^5.9.2", + "typescript-eslint": "^8.39.0" }, "extensionDependencies": [ "salesforce.salesforcedx-vscode-core" diff --git a/src/extension.ts b/src/extension.ts index 17d4f1ac..9f37fd59 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -55,7 +55,7 @@ export type SFCAExtensionData = { * Registers the necessary diagnostic collections and commands. */ export async function activate(context: vscode.ExtensionContext): Promise { - const extensionHrStart: [number, number] = process.hrtime(); + const highResStartTime: number = globalThis.performance.now(); // Helpers to keep the below code clean and so that we don't forget to push the disposables onto the context const registerCommand = (command: string, callback: (...args: unknown[]) => unknown): void => { @@ -276,7 +276,7 @@ export async function activate(context: vscode.ExtensionContext): Promise): void; sendException(name: string, errorMessage: string, properties?: Record): void; } @@ -29,7 +29,7 @@ export class LiveTelemetryService implements TelemetryService { this.logger = logger; } - sendExtensionActivationEvent(hrStart: [number, number]): void { + sendExtensionActivationEvent(hrStart: number): void { this.traceLogTelemetryEvent({hrStart}); this.coreTelemetryService.sendExtensionActivationEvent(hrStart); } @@ -59,7 +59,7 @@ export class LogOnlyTelemetryService implements TelemetryService { this.logger = logger; } - sendExtensionActivationEvent(hrStart: [number, number]): void { + sendExtensionActivationEvent(hrStart: number): void { this.traceLogTelemetryEvent({hrStart}); } diff --git a/src/test/legacy/test-utils.ts b/src/test/legacy/test-utils.ts index 0b6e6dcc..b91a6bf2 100644 --- a/src/test/legacy/test-utils.ts +++ b/src/test/legacy/test-utils.ts @@ -53,7 +53,7 @@ export class StubTelemetryService implements TelemetryService { private exceptionCalls: TelemetryExceptionData[] = []; - public sendExtensionActivationEvent(_hrStart: [number, number]): void { + public sendExtensionActivationEvent(_hrStart: number): void { // NO-OP } diff --git a/src/test/unit/stubs.ts b/src/test/unit/stubs.ts index 84faaf88..f3d90634 100644 --- a/src/test/unit/stubs.ts +++ b/src/test/unit/stubs.ts @@ -18,9 +18,9 @@ import {Workspace} from "../../lib/workspace"; export class SpyTelemetryService implements TelemetryService { - sendExtensionActivationEventCallHistory: { hrStart: [number, number] }[] = []; + sendExtensionActivationEventCallHistory: { hrStart: number }[] = []; - sendExtensionActivationEvent(hrStart: [number, number]): void { + sendExtensionActivationEvent(hrStart: number): void { this.sendExtensionActivationEventCallHistory.push({hrStart}); } From e542c2089d32537bfe9e6271b150b7228f97babb Mon Sep 17 00:00:00 2001 From: Randi Wilson Date: Thu, 7 Aug 2025 09:40:12 -0400 Subject: [PATCH 07/17] CHANGE: @W-19248649@ Complete v4 Cleanup (#265) --- .git2gus/config.json | 2 +- .github/workflows/build-tarball.yml | 9 +- .github/workflows/create-release-branch.yml | 21 +- .github/workflows/daily-smoke-test.yml | 16 +- .github/workflows/run-tests.yml | 53 +- .github/workflows/validate-pr.yml | 14 +- src/extension.ts | 13 +- src/lib/apex-lsp.ts | 43 -- src/lib/code-analyzer-run-action.ts | 2 +- src/lib/code-analyzer.ts | 137 +++++- src/lib/constants.ts | 10 - src/lib/deltarun/delta-run-service.ts | 35 -- src/lib/dfa-runner.ts | 217 --------- src/lib/messages.ts | 23 +- .../scanner-strategies/scanner-strategy.ts | 10 - src/lib/scanner-strategies/v4-scanner.ts | 162 ------- src/lib/scanner-strategies/v5-scanner.ts | 128 ----- src/lib/scanner.ts | 165 ------- src/lib/settings.ts | 54 +-- src/lib/string-utils.ts | 7 - src/lib/targeting.ts | 90 ---- src/test/legacy/apex-lsp.test.ts | 52 -- .../legacy/deltarun/delta-run-service.test.ts | 96 ---- src/test/legacy/extension.test.ts | 21 +- src/test/legacy/scanner.test.ts | 458 ------------------ src/test/legacy/settings.test.ts | 135 ------ src/test/legacy/targeting.test.ts | 112 +---- src/test/unit/lib/code-analyzer.test.ts | 28 +- src/test/unit/lib/settings.test.ts | 58 +-- src/test/unit/stubs.ts | 45 +- 30 files changed, 180 insertions(+), 2036 deletions(-) delete mode 100644 src/lib/apex-lsp.ts delete mode 100644 src/lib/deltarun/delta-run-service.ts delete mode 100644 src/lib/dfa-runner.ts delete mode 100644 src/lib/scanner-strategies/scanner-strategy.ts delete mode 100644 src/lib/scanner-strategies/v4-scanner.ts delete mode 100644 src/lib/scanner-strategies/v5-scanner.ts delete mode 100644 src/lib/scanner.ts delete mode 100644 src/lib/string-utils.ts delete mode 100644 src/test/legacy/apex-lsp.test.ts delete mode 100644 src/test/legacy/deltarun/delta-run-service.test.ts delete mode 100644 src/test/legacy/scanner.test.ts diff --git a/.git2gus/config.json b/.git2gus/config.json index 9ce6cafa..803948b7 100644 --- a/.git2gus/config.json +++ b/.git2gus/config.json @@ -1,6 +1,6 @@ { "productTag": "a1aEE000000ZZanYAG", - "defaultBuild": "scanner 2.0", + "defaultBuild": "[SFCA] Code Analyzer 5.x", "hideWorkItemUrl": true, "issueTypeLabels": { "type:feature": "USER STORY", diff --git a/.github/workflows/build-tarball.yml b/.github/workflows/build-tarball.yml index 9e472449..fa164500 100644 --- a/.github/workflows/build-tarball.yml +++ b/.github/workflows/build-tarball.yml @@ -32,13 +32,8 @@ jobs: git clone -b ${{ inputs.target-branch }} https://github.com/forcedotcom/code-analyzer.git code-analyzer cd code-analyzer # Install and build dependencies. - if [[ "${{ inputs.target-branch}}" == "dev-4" ]]; then - yarn - yarn build - else - npm install - npm run build - fi + npm install + npm run build # Create the tarball. npm pack # Upload the tarball as an artifact so it's usable elsewhere. diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 1207eb89..d43735bd 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -137,19 +137,9 @@ jobs: git push -d origin ${NEW_VERSION}-interim # Output the release branch name so we can use it in later jobs. echo "branch_name=release-$NEW_VERSION" >> "$GITHUB_OUTPUT" - # Build the tarballs so they can be installed locally when we run tests. - build-v4-scanner-tarball: - name: 'Build v4 scanner tarball' - needs: verify-should-run - uses: ./.github/workflows/build-tarball.yml - with: - # Note: Using `dev-4` here is technically incorrect. For full completeness's sake, we should probably be - # using the branch corresponding to the upcoming scanner release. However, identifying that branch is - # non-trivial, and there are unlikely to be major differences between the two that appear in the few days - # between creating the branch and releasing it, so it _should_ be fine. - target-branch: 'dev-4' - build-v5-code-analyzer-tarball: - name: 'Build v5 code-analyzer tarball' + # Build the tarball so it can be installed locally when we run tests. + build-code-analyzer-tarball: + name: 'Build code-analyzer tarball' needs: verify-should-run uses: ./.github/workflows/build-tarball.yml with: @@ -161,12 +151,11 @@ jobs: # Run all the various tests against the newly created branch. test-release-branch: name: 'Run unit tests' - needs: [build-v4-scanner-tarball, build-v5-code-analyzer-tarball, create-release-branch] + needs: [build-code-analyzer-tarball, create-release-branch] uses: ./.github/workflows/run-tests.yml with: # We want to validate the extension against whatever version of code analyzer we *plan* to publish, # not what's *already* published. use-tarballs: true - v4-tarball-suffix: 'dev-4' - v5-tarball-suffix: 'dev' + tarball-suffix: 'dev' target-branch: ${{ needs.create-release-branch.outputs.branch-name }} diff --git a/.github/workflows/daily-smoke-test.yml b/.github/workflows/daily-smoke-test.yml index fca7fa45..1fa6da0b 100644 --- a/.github/workflows/daily-smoke-test.yml +++ b/.github/workflows/daily-smoke-test.yml @@ -10,25 +10,19 @@ on: jobs: # Step 1: Build the tarballs so they can be installed locally. - build-v4-tarball: - name: 'Build v4 scanner tarball' - uses: ./.github/workflows/build-tarball.yml - with: - target-branch: 'dev-4' - build-v5-tarball: - name: 'Build v5 code analyzer tarball' + build-code-analyzer-tarball: + name: 'Build code analyzer tarball' uses: ./.github/workflows/build-tarball.yml with: target-branch: 'dev' # Step 2: Actually run the tests. smoke-test: name: 'Run smoke tests' - needs: [build-v4-tarball, build-v5-tarball] + needs: [build-code-analyzer-tarball] uses: ./.github/workflows/run-tests.yml with: use-tarballs: true - v4-tarball-suffix: 'dev-4' - v5-tarball-suffix: 'dev' + tarball-suffix: 'dev' secrets: inherit # Step 3: Build a VSIX artifact for use if needed. create-vsix-artifact: @@ -39,7 +33,7 @@ jobs: report-problems: name: 'Report problems' runs-on: ubuntu-latest - needs: [build-v4-tarball, build-v5-tarball, smoke-test, create-vsix-artifact] + needs: [build-code-analyzer-tarball, smoke-test, create-vsix-artifact] if: ${{ failure() || cancelled() }} steps: - name: Report problems diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c7a9356b..1216e2cc 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -7,13 +7,8 @@ on: required: false type: boolean default: false - v4-tarball-suffix: - description: 'The suffix attached to the name of the v4 tarball' - required: false - type: string - default: 'dev-4' - v5-tarball-suffix: - description: 'The suffix attached to the name of the v5 tarball' + tarball-suffix: + description: 'The suffix attached to the name of the code-analyzer tarball' required: false type: string default: 'dev' @@ -57,60 +52,32 @@ jobs: # of the tests are integration tests. # NOTE: SFCA can come from a tarball built in a previous step, # or be installed as the currently-latest version. - - name: Download v4 Scanner Tarball + - name: Download Code Analyzer Tarball if: ${{ inputs.use-tarballs == true }} - id: download-v4 + id: download-tarball uses: actions/download-artifact@v4 with: - name: tarball-${{ inputs.v4-tarball-suffix}} + name: tarball-${{ inputs.tarball-suffix }} # Download the tarball to a subdirectory of HOME, so it's guaranteed # to be somewhere the installation command can see. - path: ~/downloads/tarball-v4 - - name: Install v4 Scanner Tarball + path: ~/downloads/tarball + - name: Install Code Analyzer Tarball if: ${{ inputs.use-tarballs == true }} shell: bash run: | # Determine the tarball's name. - TARBALL_NAME=$(ls ~/downloads/tarball-v4/code-analyzer | grep salesforce-.*\\.tgz) + TARBALL_NAME=$(ls ~/downloads/tarball/code-analyzer | grep salesforce-.*\\.tgz) echo $TARBALL_NAME # Figure out where the tarball was downloaded to. # To allow compatibility with Windows, replace backslashes with forward slashes # and rip off a leading `C:` if present. - DOWNLOAD_PATH=`echo '${{ steps.download-v4.outputs.download-path }}' | tr '\\' '/'` + DOWNLOAD_PATH=`echo '${{ steps.download-tarball.outputs.download-path }}' | tr '\\' '/'` echo $DOWNLOAD_PATH DOWNLOAD_PATH=`[[ $DOWNLOAD_PATH = C* ]] && echo $DOWNLOAD_PATH | cut -d':' -f 2 || echo $DOWNLOAD_PATH` echo $DOWNLOAD_PATH # Pipe in a `y` to simulate agreeing to install an unsigned package. Use a URI of the file's full path. echo y | sf plugins install "file://${DOWNLOAD_PATH}/code-analyzer/${TARBALL_NAME}" - - name: Download v5 Code Analyzer Tarball - if: ${{ inputs.use-tarballs == true }} - id: download-v5 - uses: actions/download-artifact@v4 - with: - name: tarball-${{ inputs.v5-tarball-suffix }} - # Download the tarball to a subdirectory of HOME, so it's guaranteed - # to be somewhere the installation command can see. - path: ~/downloads/tarball-v5 - - name: Install v5 Code Analyzer Tarball - if: ${{ inputs.use-tarballs == true }} - shell: bash - run: | - # Determine the tarball's name. - TARBALL_NAME=$(ls ~/downloads/tarball-v5/code-analyzer | grep salesforce-.*\\.tgz) - echo $TARBALL_NAME - # Figure out where the tarball was downloaded to. - # To allow compatibility with Windows, replace backslashes with forward slashes - # and rip off a leading `C:` if present. - DOWNLOAD_PATH=`echo '${{ steps.download-v5.outputs.download-path }}' | tr '\\' '/'` - echo $DOWNLOAD_PATH - DOWNLOAD_PATH=`[[ $DOWNLOAD_PATH = C* ]] && echo $DOWNLOAD_PATH | cut -d':' -f 2 || echo $DOWNLOAD_PATH` - echo $DOWNLOAD_PATH - # Pipe in a `y` to simulate agreeing to install an unsigned package. Use a URI of the file's full path. - echo y | sf plugins install "file://${DOWNLOAD_PATH}/code-analyzer/${TARBALL_NAME}" - - name: Install Production scanner v4 - if: ${{ inputs.use-tarballs == false }} - run: sf plugins install @salesforce/sfdx-scanner - - name: Install Production code-analyzer v5 + - name: Install Production code-analyzer if: ${{ inputs.use-tarballs == false }} run: sf plugins install code-analyzer # Run the tests. (Linux and non-Linux need slightly different commands.) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 05e755ff..792454ec 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -47,27 +47,21 @@ jobs: echo "Valid PR title: '$title'" # RUN TESTS # Step 1: Build the tarball so it can be installed locally. - build_v4_scanner_tarball: - name: 'Build v4 scanner tarball' - uses: ./.github/workflows/build-tarball.yml - with: - target-branch: 'dev-4' - build_v5_code_analyzer_tarball: - name: 'Build v5 code analyzer tarball' + build_code_analyzer_tarball: + name: 'Build code analyzer tarball' uses: ./.github/workflows/build-tarball.yml with: target-branch: 'dev' # Step 2: Actually run the tests. run_tests: name: 'Run unit tests' - needs: [build_v4_scanner_tarball, build_v5_code_analyzer_tarball] + needs: [build_code_analyzer_tarball] uses: ./.github/workflows/run-tests.yml with: # We want to validate the extension against whatever version of code analyzer we # *plan* to publish, not what's *already* published. use-tarballs: true - v4-tarball-suffix: 'dev-4' - v5-tarball-suffix: 'dev' + tarball-suffix: 'dev' # BUILD A VSIX ARTIFACT # Additionally, build a VSIX that can be downloaded by the user if needed. create-vsix-artifact: diff --git a/src/extension.ts b/src/extension.ts index 9f37fd59..31c0dcbc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,6 @@ import * as ApexGuruFunctions from './lib/apexguru/apex-guru-service'; import {ExternalServiceProvider} from "./lib/external-services/external-service-provider"; import {Logger, LoggerImpl} from "./lib/logger"; import {TelemetryService} from "./lib/external-services/telemetry-service"; -import {DfaRunner} from "./lib/dfa-runner"; import {CodeAnalyzerRunAction} from "./lib/code-analyzer-run-action"; import {A4DFixActionProvider} from "./lib/agentforce/a4d-fix-action-provider"; import {ScanManager} from './lib/scan-manager'; @@ -95,8 +94,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { const selection: vscode.Uri[] = (multiSelection && multiSelection.length > 0) ? multiSelection : [singleSelection]; const workspace: Workspace = await Workspace.fromTargetPaths(selection.map(uri => uri.fsPath), vscodeWorkspace, fileHandler); @@ -295,15 +292,15 @@ export async function deactivate(): Promise { // TODO: We either need to give the user control over which files the auto-scan on open/save feature works for... // ... or we need to somehow determine dynamically if the file is relevant for scanning using the -// ... --workspace option on Code Analyzer v5 or something. I think that regex has situations that work on all -// ....files. So We might not be able to get this perfect. Need to discuss this soon. +// ... --workspace option. I think that regex has situations that work on all +// ....files. So we might not be able to get this perfect. Need to discuss this soon. export function _isValidFileForAnalysis(documentUri: vscode.Uri): boolean { const allowedFileTypes:string[] = ['.cls', '.js', '.apex', '.trigger', '.ts', '.xml']; return allowedFileTypes.includes(path.extname(documentUri.fsPath)); } // Inside our package.json you'll see things like: -// "when": "sfca.partialRunsEnabled && sfca.codeAnalyzerV4Enabled" +// "when": "sfca.apexGuruEnabled" // which helps determine when certain commands and menus are available. // To make these "context variables" set and stay updated when settings change, use this helper function: async function establishVariableInContext(varUsedInPackageJson: string, getValueFcn: () => Promise): Promise { @@ -330,7 +327,7 @@ async function getActiveDocument(): Promise { async function performValidationAndCaching(codeAnalyzer: CodeAnalyzer, display: Display): Promise { try { await codeAnalyzer.validateEnvironment(); - // Note: We might consider adding in additional things here like for v5 getting the rule descriptions, etc. + // Note: We might consider adding in additional things here like for getting the rule descriptions, etc. } catch (err) { display.displayError(getErrorMessage(err)); } diff --git a/src/lib/apex-lsp.ts b/src/lib/apex-lsp.ts deleted file mode 100644 index 3048ffa3..00000000 --- a/src/lib/apex-lsp.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as vscode from 'vscode'; - -/** - * VSCode's {@code executeDocumentSymbolProvider} command can return either an - * array of either {@link vscode.DocumentSymbol}s or {@link vscode.SymbolInformation}s. - * This type avoids having to type out {@code vscode.DocumentSymbol | vscode.SymbolInformation} - * repeatedly. - */ -export type GenericSymbol = vscode.DocumentSymbol | vscode.SymbolInformation; - -/** - * Class that handles interactions with the Apex Language Server. - */ -export class ApexLsp { - - /** - * Get an array of {@link GenericSymbol}s indicating the classes, methods, etc defined - * in the provided file. - * @param documentUri - * @returns An array of symbols if the server is available, otherwise empty - */ - public static async getSymbols(documentUri: vscode.Uri): Promise { - const hierarchicalSymbols: GenericSymbol[] = (await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', documentUri)) || []; - return flattenSymbols(hierarchicalSymbols); - } -} - -function flattenSymbols(symbols: GenericSymbol[]): GenericSymbol[] { - const flattened: GenericSymbol[] = []; - for (const symbol of symbols) { - flattened.push(symbol); - if ('children' in symbol) { - flattened.push(...flattenSymbols(symbol.children)); // Recursively flatten children - } - } - return flattened; -} diff --git a/src/lib/code-analyzer-run-action.ts b/src/lib/code-analyzer-run-action.ts index 60422fde..2a09561c 100644 --- a/src/lib/code-analyzer-run-action.ts +++ b/src/lib/code-analyzer-run-action.ts @@ -59,7 +59,7 @@ export class CodeAnalyzerRunAction { message: messages.scanProgressReport.analyzingTargets, increment: 20 }); - this.logger.log(messages.info.scanningWith(await this.codeAnalyzer.getScannerName())); + this.logger.log(messages.info.scanningWith(await this.codeAnalyzer.getVersion())); const violations: Violation[] = await this.codeAnalyzer.scan(workspace); progressReporter.reportProgress({ diff --git a/src/lib/code-analyzer.ts b/src/lib/code-analyzer.ts index 6fadc41a..99edb28b 100644 --- a/src/lib/code-analyzer.ts +++ b/src/lib/code-analyzer.ts @@ -1,31 +1,53 @@ import {Violation} from "./diagnostics"; -import {CliScannerV5Strategy} from "./scanner-strategies/v5-scanner"; import {SettingsManager} from "./settings"; import {Display} from "./display"; import {messages} from './messages'; -import {CliCommandExecutor} from "./cli-commands"; +import {CliCommandExecutor, CommandOutput} from "./cli-commands"; import * as semver from 'semver'; import { ABSOLUTE_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION, RECOMMENDED_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION } from "./constants"; -import {CliScannerStrategy} from "./scanner-strategies/scanner-strategy"; import {FileHandler, FileHandlerImpl} from "./fs-utils"; import {Workspace} from "./workspace"; +import * as vscode from "vscode"; +import * as fs from 'node:fs'; +import * as path from 'node:path'; -export interface CodeAnalyzer extends CliScannerStrategy { +type ResultsJson = { + runDir: string; + violations: Violation[]; +}; + +type RulesJson = { + rules: RuleDescription[]; +} + +type RuleDescription = { + name: string, + description: string, + engine: string, + severity: number, + tags: string[], + resources: string[] +} + +export interface CodeAnalyzer { validateEnvironment(): Promise; + scan(workspace: Workspace): Promise; + getVersion(): Promise; + getRuleDescriptionFor(engineName: string, ruleName: string): Promise; } export class CodeAnalyzerImpl implements CodeAnalyzer { private readonly cliCommandExecutor: CliCommandExecutor; private readonly settingsManager: SettingsManager; private readonly display: Display; - private readonly fileHandler: FileHandler + private readonly fileHandler: FileHandler; private cliIsInstalled: boolean = false; - - private codeAnalyzerV5?: CliScannerV5Strategy; + private version?: semver.SemVer; + private ruleDescriptionMap?: Map; constructor(cliCommandExecutor: CliCommandExecutor, settingsManager: SettingsManager, display: Display, fileHandler: FileHandler = new FileHandlerImpl()) { @@ -42,16 +64,11 @@ export class CodeAnalyzerImpl implements CodeAnalyzer { } this.cliIsInstalled = true; } - await this.validateV5Plugin(); - } - - private async getDelegate(): Promise { - await this.validateEnvironment(); - return this.codeAnalyzerV5; + await this.validatePlugin(); } - private async validateV5Plugin(): Promise { - if (this.codeAnalyzerV5 !== undefined) { + private async validatePlugin(): Promise { + if (this.version !== undefined) { return; // Already validated } const absMinVersion: semver.SemVer = new semver.SemVer(ABSOLUTE_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION); @@ -67,18 +84,94 @@ export class CodeAnalyzerImpl implements CodeAnalyzer { this.display.displayWarning(messages.codeAnalyzer.usingOlderVersion(installedVersion.toString(), recommendedMinVersion.toString()) + '\n' + messages.codeAnalyzer.installLatestVersion); } - this.codeAnalyzerV5 = new CliScannerV5Strategy(installedVersion, this.cliCommandExecutor, this.settingsManager, this.fileHandler); + this.version = installedVersion; } - async scan(workspace: Workspace): Promise { - return (await this.getDelegate()).scan(workspace); + public async getVersion(): Promise { + await this.validateEnvironment(); + return this.version?.toString() || 'unknown'; } - async getScannerName(): Promise { - return (await this.getDelegate()).getScannerName(); + public async getRuleDescriptionFor(engineName: string, ruleName: string): Promise { + await this.validateEnvironment(); + return (await this.getRuleDescriptionMap()).get(`${engineName}:${ruleName}`) || ''; } - async getRuleDescriptionFor(engineName: string, ruleName: string): Promise { - return (await this.getDelegate()).getRuleDescriptionFor(engineName, ruleName); + private async getRuleDescriptionMap(): Promise> { + if (this.ruleDescriptionMap === undefined) { + if (this.version && semver.gte(this.version, '5.0.0-beta.3')) { + this.ruleDescriptionMap = await this.createRuleDescriptionMap(); + } else { + this.ruleDescriptionMap = new Map(); + } + } + return this.ruleDescriptionMap; + } + + public async scan(workspace: Workspace): Promise { + await this.validateEnvironment(); + + const ruleSelector: string = this.settingsManager.getCodeAnalyzerRuleSelectors(); + const configFile: string = this.settingsManager.getCodeAnalyzerConfigFile(); + + const args: string[] = ['code-analyzer', 'run']; + + if (this.version && semver.gte(this.version, '5.0.0')) { + workspace.getRawWorkspacePaths().forEach(p => args.push('-w', p)); + workspace.getRawTargetPaths().forEach(p => args.push('-t', p)); + } else { + // Before 5.0.0 the --target flag did not exist, so we just make the workspace equal to the target paths + workspace.getRawTargetPaths().forEach(p => args.push('-w', p)); + } + + if (ruleSelector) { + args.push('-r', ruleSelector); + } + if (configFile) { + args.push('-c', configFile); + } + + const outputFile: string = await this.fileHandler.createTempFile('.json'); + args.push('-f', outputFile); + + const commandOutput: CommandOutput = await this.cliCommandExecutor.exec('sf', args, {logLevel: vscode.LogLevel.Debug}); + if (commandOutput.exitCode !== 0) { + throw new Error(commandOutput.stderr); + } + + const resultsJsonStr: string = await fs.promises.readFile(outputFile, 'utf-8'); + const resultsJson: ResultsJson = JSON.parse(resultsJsonStr) as ResultsJson; + return this.processResults(resultsJson); + } + + private processResults(resultsJson: ResultsJson): Violation[] { + const processedViolations: Violation[] = []; + for (const violation of resultsJson.violations) { + for (const location of violation.locations) { + // If the path isn't already absolute, it needs to be made absolute. + if (location.file && path.resolve(location.file).toLowerCase() !== location.file.toLowerCase()) { + // Relative paths are relative to the RunDir results property. + location.file = path.join(resultsJson.runDir, location.file); + } + } + processedViolations.push(violation); + } + return processedViolations; + } + + private async createRuleDescriptionMap(): Promise> { + const outputFile: string = await this.fileHandler.createTempFile('.json'); + const commandOutput: CommandOutput = await this.cliCommandExecutor.exec('sf', ['code-analyzer', 'rules', '-r', 'all', '-f', outputFile]); + if (commandOutput.exitCode !== 0) { + throw new Error(commandOutput.stderr); + } + const rulesJsonStr: string = await fs.promises.readFile(outputFile, 'utf-8'); + const rulesOutput: RulesJson = JSON.parse(rulesJsonStr) as RulesJson; + + const ruleDescriptionMap: Map = new Map(); + for (const ruleDescription of rulesOutput.rules) { + ruleDescriptionMap.set(`${ruleDescription.engine}:${ruleDescription.name}`, ruleDescription.description); + } + return ruleDescriptionMap; } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 700d72a0..73ed2ae7 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -14,8 +14,6 @@ export const EXTENSION_PACK_ID = 'salesforce.salesforcedx-vscode'; // command names. These must exactly match the declarations in `package.json`. export const COMMAND_RUN_ON_ACTIVE_FILE = 'sfca.runOnActiveFile'; export const COMMAND_RUN_ON_SELECTED = 'sfca.runOnSelected'; -export const COMMAND_RUN_DFA_ON_SELECTED_METHOD = 'sfca.runDfaOnSelectedMethod'; -export const COMMAND_RUN_DFA = 'sfca.runDfa'; export const COMMAND_REMOVE_DIAGNOSTICS_ON_ACTIVE_FILE = 'sfca.removeDiagnosticsOnActiveFile'; export const COMMAND_REMOVE_DIAGNOSTICS_ON_SELECTED_FILE = 'sfca.removeDiagnosticsOnSelectedFile'; export const COMMAND_RUN_APEX_GURU_ON_FILE = 'sfca.runApexGuruAnalysisOnSelectedFile'; @@ -30,11 +28,8 @@ export const QF_COMMAND_APPLY_VIOLATION_FIXES = 'sfca.applyViolationFixes'; export const VSCODE_COMMAND_OPEN_URL = 'vscode.open'; // telemetry event keys -export const TELEM_SETTING_USEV4 = 'sfdx__codeanalyzer_setting_useV4'; export const TELEM_SUCCESSFUL_STATIC_ANALYSIS = 'sfdx__codeanalyzer_static_run_complete'; export const TELEM_FAILED_STATIC_ANALYSIS = 'sfdx__codeanalyzer_static_run_failed'; -export const TELEM_SUCCESSFUL_DFA_ANALYSIS = 'sfdx__codeanalyzer_dfa_run_complete'; -export const TELEM_FAILED_DFA_ANALYSIS = 'sfdx__codeanalyzer_dfa_run_failed'; export const TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS = 'sfdx__apexguru_file_run_complete'; // telemetry keys used by eGPT (A4D) @@ -55,9 +50,6 @@ export const MINIMUM_REQUIRED_VERSION_CORE_EXTENSION = '58.4.1'; export const RECOMMENDED_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0'; export const ABSOLUTE_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0-beta.0'; -// cache names -export const WORKSPACE_DFA_PROCESS = 'dfaScanProcess'; - // apex guru APIS export const APEX_GURU_AUTH_ENDPOINT = '/services/data/v62.0/apexguru/validate' export const APEX_GURU_REQUEST = '/services/data/v62.0/apexguru/request' @@ -66,8 +58,6 @@ export const APEX_GURU_RETRY_INTERVAL_MILLIS = 1000; // Context variables (dynamically set but consumed by the "when" conditions in the package.json "contributes" sections) export const CONTEXT_VAR_EXTENSION_ACTIVATED = 'sfca.extensionActivated'; -export const CONTEXT_VAR_V4_ENABLED = 'sfca.codeAnalyzerV4Enabled'; -export const CONTEXT_VAR_PARTIAL_RUNS_ENABLED = 'sfca.partialRunsEnabled'; export const CONTEXT_VAR_APEX_GURU_ENABLED = 'sfca.apexGuruEnabled'; // Documentation URLs diff --git a/src/lib/deltarun/delta-run-service.ts b/src/lib/deltarun/delta-run-service.ts deleted file mode 100644 index 719934ed..00000000 --- a/src/lib/deltarun/delta-run-service.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2024, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as fs from 'fs'; - -export function getDeltaRunTarget(sfgecachepath:string, savedFilesCache:Set): string[] { - // Read and parse the JSON file at sfgecachepath - const fileContent = fs.readFileSync(sfgecachepath, 'utf-8'); - const parsedData = JSON.parse(fileContent) as CacheData; - - const matchingEntries: string[] = []; - - // Iterate over each file entry in the data - parsedData.data.forEach((entry: { filename: string, entries: string[] }) => { - // Check if the filename is in the savedFilesCache - if (savedFilesCache.has(entry.filename)) { - // If it matches, add the individual entries to the result array - matchingEntries.push(...entry.entries); - } - }); - - return matchingEntries; -} - -interface CacheEntry { - filename: string; - entries: string[]; -} - -interface CacheData { - data: CacheEntry[]; -} \ No newline at end of file diff --git a/src/lib/dfa-runner.ts b/src/lib/dfa-runner.ts deleted file mode 100644 index 3af8e79b..00000000 --- a/src/lib/dfa-runner.ts +++ /dev/null @@ -1,217 +0,0 @@ -import * as vscode from "vscode"; -import {TelemetryService} from "./external-services/telemetry-service"; -import {Logger} from "./logger"; -import * as Constants from "./constants"; -import {messages} from "./messages"; -import * as DeltaRunFunctions from "./deltarun/delta-run-service"; -import fs from "fs"; -import path from "path"; -import * as targeting from "./targeting"; -import os from "os"; -import {ScanRunner} from "./scanner"; -import {SIGKILL} from "constants"; -import {CodeAnalyzer} from "./code-analyzer"; -import {CliCommandExecutorImpl} from "./cli-commands"; -import {SettingsManagerImpl} from "./settings"; - -export class DfaRunner implements vscode.Disposable { - private readonly sfgeCachePath: string = path.join(createTempDirectory(), 'sfca-graph-engine-cache.json'); - private readonly savedFilesCache: Set = new Set(); - - private readonly context: vscode.ExtensionContext; - private readonly codeAnalyzer: CodeAnalyzer; - private readonly telemetryService: TelemetryService; - private readonly logger: Logger; - - constructor(context: vscode.ExtensionContext, codeAnalyzer: CodeAnalyzer, telemetryService: TelemetryService, logger: Logger) { - this.context = context; - this.codeAnalyzer = codeAnalyzer; - this.telemetryService = telemetryService; - this.logger = logger; - } - - dispose(): void { - this.clearSavedFilesCache(); - - // TODO: We should consider maybe making the sfgeCachePath's parent temp directory creation JIT and async and - // then have a way of deleting the directory and all of its contents here during dispose(). - } - - clearSavedFilesCache() { - this.savedFilesCache.clear(); - } - - addSavedFileToCache(filePath: string) { - this.savedFilesCache.add(filePath); - } - - async shouldProceedWithDfaRun(): Promise { - if (this.context.workspaceState.get(Constants.WORKSPACE_DFA_PROCESS)) { - void vscode.window.showInformationMessage(messages.graphEngine.existingDfaRunText); - return false; - } - return Promise.resolve(true); - } - - async runDfa(): Promise { - if (this.violationsCacheExists()) { - const partialScanText = 'Partial scan: Scan only the code that you changed since the previous scan.'; - const fullScanText = 'Full scan: Scan all the code in this project again.'; - const choice = await vscode.window.showQuickPick( - [partialScanText, fullScanText], - { - placeHolder: 'You previously scanned this code using Salesforce Graph Engine. What kind of scan do you want to run now?', - canPickMany: false, - ignoreFocusOut: true - } - ); - - // Default to "Yes" if no choice is made - const rerunChangedOnly = choice == partialScanText; - if (rerunChangedOnly) { - const deltaRunTargets = DeltaRunFunctions.getDeltaRunTarget(this.sfgeCachePath, this.savedFilesCache); - if (deltaRunTargets.length == 0) { - void vscode.window.showInformationMessage("Your local changes didn't change the outcome of the previous full Salesforce Graph Engine scan."); - return; - } - await this.runDfaOnSelectMethods(deltaRunTargets); - } else { - void vscode.window.showWarningMessage('A full Salesforce Graph Engine scan is running in the background. You can cancel it by clicking the progress bar.'); - await this.runDfaOnWorkspace(); - } - } else { - await this.runDfaOnWorkspace(); - } - } - - private async runDfaOnWorkspace(): Promise { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Window, - title: messages.graphEngine.spinnerText, - cancellable: true - }, async (_progress, token) => { - token.onCancellationRequested(async () => await this.stopExistingDfaRun()); - - const customCancellationToken: vscode.CancellationTokenSource = new vscode.CancellationTokenSource(); - customCancellationToken.token.onCancellationRequested(() => - void vscode.window.showInformationMessage(messages.graphEngine.noViolationsFound)); - - // We only have one project loaded on VSCode at once. So, projectDir should have only one entry and we use - // the root directory of that project as the projectDir argument to run DFA. - return this._runAndDisplayDfa(Constants.COMMAND_RUN_DFA, customCancellationToken, null, - targeting.getProjectDir()); - }); - } - - private async runDfaOnSelectMethods(selectedMethods: string[]) { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Window, - title: messages.graphEngine.spinnerText, - cancellable: true - }, async (_progress, token) => { - token.onCancellationRequested(async () => await this.stopExistingDfaRun()); - - const customCancellationToken: vscode.CancellationTokenSource = new vscode.CancellationTokenSource(); - customCancellationToken.token.onCancellationRequested(() => - void vscode.window.showInformationMessage(messages.graphEngine.noViolationsFoundForPartialRuns)); - - // We only have one project loaded on VSCode at once. So, projectDir should have only one entry and we use - // the root directory of that project as the projectDir argument to run DFA. - return this._runAndDisplayDfa(Constants.COMMAND_RUN_DFA, customCancellationToken, - selectedMethods, targeting.getProjectDir()); - }); - } - - async runMethodLevelDfa(methodLevelTarget: string[]) { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Window, - title: messages.graphEngine.spinnerText, - cancellable: true - }, async (_progress, token) => { - token.onCancellationRequested(async () => await this.stopExistingDfaRun()); - - const customCancellationToken: vscode.CancellationTokenSource = new vscode.CancellationTokenSource(); - customCancellationToken.token.onCancellationRequested(() => - void vscode.window.showInformationMessage(messages.graphEngine.noViolationsFound)); - - // Pull out the file from the target and use it to identify the project directory. - const currentFile: string = methodLevelTarget[0].substring(0, methodLevelTarget.lastIndexOf('#')); - const projectDir: string = targeting.getProjectDir(currentFile); - - return this._runAndDisplayDfa(Constants.COMMAND_RUN_DFA_ON_SELECTED_METHOD, customCancellationToken, - methodLevelTarget, projectDir); - }); - } - - // public for testing purposes only - async _runAndDisplayDfa(commandName: string, cancelToken: vscode.CancellationTokenSource, methodLevelTarget: string[], - projectDir: string): Promise { - const startTime = Date.now(); - try { - await this.codeAnalyzer.validateEnvironment(); // Since the ScanRunner currently doesn't take in the codeAnalyzer to run dfa commands, we just validate here - const scanRunner: ScanRunner = new ScanRunner(new SettingsManagerImpl(), new CliCommandExecutorImpl(this.logger)); - const results = await scanRunner.runDfa(methodLevelTarget, projectDir, this.context, this.sfgeCachePath); - if (results.length > 0) { - const panel = vscode.window.createWebviewPanel( - 'dfaResults', - messages.graphEngine.resultsTab, - vscode.ViewColumn.Two, - { - enableScripts: true - } - ); - panel.webview.html = results; - } else { - cancelToken.cancel(); - } - this.telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_DFA_ANALYSIS, { - executedCommand: commandName, - duration: (Date.now() - startTime).toString() - }); - } catch (e) { - const errMsg = e instanceof Error ? e.message : e as string; - this.telemetryService.sendException(Constants.TELEM_FAILED_DFA_ANALYSIS, errMsg, { - executedCommand: commandName, - duration: (Date.now() - startTime).toString() - }); - // This has to be a floating promise, since the command won't complete until - // the error is dismissed. - vscode.window.showErrorMessage(messages.error.analysisFailedGenerator(errMsg)); - this.logger.error(errMsg); - } - } - - async stopExistingDfaRun(): Promise { - const pid = this.context.workspaceState.get(Constants.WORKSPACE_DFA_PROCESS); - if (pid) { - try { - process.kill(pid as number, SIGKILL); - void this.context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - void vscode.window.showInformationMessage(messages.graphEngine.dfaRunStopped); - } catch (e) { - // Exception is thrown by process.kill if between the time the pid exists and kill is executed, the process - // ends by itself. Ideally it should clear the cache, but doing this as an abundant of caution. - void this.context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - const errMsg = e instanceof Error ? e.message : e as string; - this.logger.error(`Failed killing DFA process.\n${errMsg}`); - } - } else { - void vscode.window.showInformationMessage(messages.graphEngine.noDfaRun); - } - return Promise.resolve(); - } - - private violationsCacheExists(): boolean { - return fs.existsSync(this.sfgeCachePath); - } -} - -function createTempDirectory(): string { - const tempFolderPrefix = path.join(os.tmpdir(), Constants.EXTENSION_PACK_ID); - try { - return fs.mkdtempSync(tempFolderPrefix); - } catch (err) { - const errMsg: string = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to create temporary directory:\n${errMsg}`); - } -} diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 920e925b..9a3c82da 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -7,7 +7,6 @@ export const messages = { noActiveEditor: "Unable to perform action: No active editor.", staleDiagnosticPrefix: "(STALE: The code has changed. Re-run the scan.)", - stoppingV4SupportSoon: "We no longer support Code Analyzer v4 and will soon remove it from this VS Code extension. We highly recommend that you start using v5 by unselecting the 'Code Analyzer: Use v4 (Deprecated)' setting. For information on v5, see https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/code-analyzer.html.", scanProgressReport: { verifyingCodeAnalyzerIsInstalled: "Verifying Code Analyzer CLI plugin is installed.", identifyingTargets: "Code Analyzer is identifying targets.", @@ -29,19 +28,9 @@ export const messages = { finishedScan: (violationCount: number) => `Scan complete. ${violationCount} violations found.` }, info: { - scanningWith: (scannerName: string) => `Scanning with ${scannerName}`, + scanningWith: (version: string) => `Scanning with code-analyzer@${version} via CLI`, finishedScan: (scannedCount: number, badFileCount: number, violationCount: number) => `Scan complete. Analyzed ${scannedCount} files. ${violationCount} violations found in ${badFileCount} files.` }, - graphEngine: { - noViolationsFound: "Scan was completed. No violations found.", - noViolationsFoundForPartialRuns: "Partial Salesforce Graph Engine scan of the changed code completed, and no violations found. IMPORTANT: You might still have violations in the code that you haven't changed since the previous full scan.", - resultsTab: "Graph Engine Results", - spinnerText: 'Running Graph Engine analysis...', - statusBarName: "Graph Engine Analysis", - noDfaRun: "We didn't find a running Salesforce Graph Engine analysis, so nothing was canceled.", - dfaRunStopped: "Salesforce Graph Engine analysis canceled.", - existingDfaRunText: "A Salesforce Graph Engine analysis is already running. Cancel it by clicking in the Status Bar.", - }, fixer: { suppressPMDViolationsOnLine: "Suppress all 'pmd' violations on this line", suppressPmdViolationsOnClass: (ruleName: string) => `Suppress 'pmd.${ruleName}' on this class`, @@ -56,13 +45,9 @@ export const messages = { } }, targeting: { - warnings: { - apexLspUnavailable: "Apex Language Server is unavailable. Defaulting to strict targeting." - }, error: { nonexistentSelectedFileGenerator: (file: string) => `Selected file doesn't exist: ${file}`, - noFileSelected: "Select a file to scan", - noMethodIdentified: "Select a single method to run Graph Engine path-based analysis." + noFileSelected: "Select a file to scan" } }, codeAnalyzer: { @@ -76,14 +61,12 @@ export const messages = { engineUninstantiable: (engine: string) => `Error: Couldn't initialize engine "${engine}" due to a setup error. Analysis continued without this engine. Click "Show error" to see the error message. Click "Ignore error" to ignore the error for this session. Click "Learn more" to view the system requirements for this engine, and general instructions on how to set up Code Analyzer.`, pmdConfigNotFoundGenerator: (file: string) => `PMD custom config file couldn't be located. [${file}]. Check Salesforce Code Analyzer > PMD > Custom Config settings`, sfMissing: "To use the Salesforce Code Analyzer extension, first install Salesforce CLI.", - sfdxScannerMissing: "To use the 'Code Analyzer: Use v4 (Deprecated)' setting, you must first install the `@salesforce/sfdx-scanner` Salesforce CLI plugin. But we no longer support v4, so we recommend that you use v5 instead and unselect the 'Code Analyzer: Use v4 (Deprecated)' setting.", coreExtensionServiceUninitialized: "CoreExtensionService.ts didn't initialize. Log a new issue on Salesforce Code Analyzer VS Code extension repo: https://github.com/forcedotcom/sfdx-code-analyzer-vscode/issues" }, buttons: { learnMore: 'Learn more', showError: 'Show error', ignoreError: 'Ignore error', - showSettings: 'Show settings', - startUsingV5: 'Start using v5' + showSettings: 'Show settings' } }; diff --git a/src/lib/scanner-strategies/scanner-strategy.ts b/src/lib/scanner-strategies/scanner-strategy.ts deleted file mode 100644 index 1f450aa2..00000000 --- a/src/lib/scanner-strategies/scanner-strategy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {Violation} from '../diagnostics'; -import {Workspace} from "../workspace"; - -export interface CliScannerStrategy { - scan(workspace: Workspace): Promise; - - getScannerName(): Promise; - - getRuleDescriptionFor(engineName: string, ruleName: string): Promise; -} diff --git a/src/lib/scanner-strategies/v4-scanner.ts b/src/lib/scanner-strategies/v4-scanner.ts deleted file mode 100644 index 758b0d0e..00000000 --- a/src/lib/scanner-strategies/v4-scanner.ts +++ /dev/null @@ -1,162 +0,0 @@ -import * as vscode from 'vscode'; -import {CliScannerStrategy} from './scanner-strategy'; -import {Violation} from '../diagnostics'; -import {messages} from '../messages'; -import {SettingsManager} from "../settings"; -import * as semver from 'semver'; -import {CliCommandExecutor, CommandOutput} from "../cli-commands"; -import {FileHandler} from "../fs-utils"; -import {Workspace} from "../workspace"; - -export type BaseV4Violation = { - ruleName: string; - message: string; - severity: number; - normalizedSeverity?: number; - category: string; - url?: string; - exception?: boolean; -}; - -export type PathlessV4RuleViolation = BaseV4Violation & { - line: number; - column: number; - endLine?: number; - endColumn?: number; -}; - -export type DfaV4RuleViolation = BaseV4Violation & { - sourceLine: number; - sourceColumn: number; - sourceType: string; - sourceMethodName: string; - sinkLine: number|null; - sinkColumn: number|null; - sinkFileName: string|null; -}; - -export type V4RuleViolation = PathlessV4RuleViolation | DfaV4RuleViolation; - -export type V4RuleResult = { - engine: string; - fileName: string; - violations: V4RuleViolation[]; -}; - -export type V4ExecutionResult = { - status: number; - result?: V4RuleResult[]|string; - warnings?: string[]; - message?: string; -}; - -export class CliScannerV4Strategy implements CliScannerStrategy { - private readonly version: semver.SemVer; - private readonly cliCommandExecutor: CliCommandExecutor; - private readonly settingsManager: SettingsManager; - private readonly fileHandler: FileHandler; - - public constructor(version: semver.SemVer, cliCommandExecutor: CliCommandExecutor, settingsManager: SettingsManager, fileHandler: FileHandler) { - this.version = version; - this.cliCommandExecutor = cliCommandExecutor; - this.settingsManager = settingsManager; - this.fileHandler = fileHandler; - } - - public getScannerName(): Promise { - return Promise.resolve(`@salesforce/sfdx-scanner@${this.version.toString()} via CLI`); - } - - public async scan(workspace: Workspace): Promise { - // Create the arg array. - const args: string[] = await this.createArgArray(workspace); - - // Invoke the scanner. - const executionResult: V4ExecutionResult = await this.invokeAnalyzer(args); - - // Process the results. - return this.processResults(executionResult); - } - - private async createArgArray(workspace: Workspace): Promise { - const engines: string = this.settingsManager.getEnginesToRun(); - const pmdCustomConfigFile: string | undefined = this.settingsManager.getPmdCustomConfigFile(); - const rulesCategory: string | undefined = this.settingsManager.getRulesCategory(); - const normalizeSeverity: boolean = this.settingsManager.getNormalizeSeverityEnabled(); - - if (engines.length === 0) { - throw new Error('"Code Analyzer > Scanner: Engines" setting can\'t be empty. Go to your VS Code settings and specify at least one engine, and then try again.'); - } - - const args: string[] = [ - 'scanner', 'run', - '--target', `${workspace.getRawTargetPaths().join(',')}`, - `--engine`, engines, - `--json` - ]; - if (pmdCustomConfigFile?.length > 0) { - if (!(await this.fileHandler.exists(pmdCustomConfigFile))) { - throw new Error(messages.error.pmdConfigNotFoundGenerator(pmdCustomConfigFile)); - } - args.push('--pmdconfig', pmdCustomConfigFile); - } - - if (rulesCategory) { - args.push('--category', rulesCategory); - } - - if (normalizeSeverity) { - args.push('--normalize-severity'); - } - return args; - } - - public getRuleDescriptionFor(_engineName: string, _ruleName: string): Promise { - // Currently the rule descriptions are nice-to-have to help provide additional context for A4D. - // So for users still using v4, we don't really need to fill this in. We want users to migrate to v5 anyway. - return Promise.resolve(''); - } - - private async invokeAnalyzer(args: string[]): Promise { - const commandOutput: CommandOutput = await this.cliCommandExecutor.exec('sf', args, {logLevel: vscode.LogLevel.Debug}); - // No matter what, stdout will be an execution result. - return JSON.parse(commandOutput.stdout) as V4ExecutionResult; - } - - private processResults(executionResult: V4ExecutionResult): Violation[] { - // 0 is the status code for a successful analysis. - if (executionResult.status === 0) { - // If the results were a string, that indicates that no results were found. - if (typeof executionResult.result === 'string') { - return []; - } else { - const convertedResults: Violation[] = []; - for (const {engine, fileName, violations} of executionResult.result) { - for (const violation of violations) { - const pathlessViolation: PathlessV4RuleViolation = violation as PathlessV4RuleViolation; - convertedResults.push({ - rule: pathlessViolation.ruleName, - engine, - message: pathlessViolation.message, - severity: pathlessViolation.severity, - locations: [{ - file: fileName, - startLine: pathlessViolation.line, - startColumn: pathlessViolation.column, - endLine: pathlessViolation.endLine, - endColumn: pathlessViolation.endColumn, - }], - primaryLocationIndex: 0, - tags: [], - resources: pathlessViolation.url ? [pathlessViolation.url] : [] - }); - } - } - return convertedResults; - } - } else { - // Any other status code indicates an error of some kind. - throw new Error(executionResult.message); - } - } -} diff --git a/src/lib/scanner-strategies/v5-scanner.ts b/src/lib/scanner-strategies/v5-scanner.ts deleted file mode 100644 index d5b93c2d..00000000 --- a/src/lib/scanner-strategies/v5-scanner.ts +++ /dev/null @@ -1,128 +0,0 @@ -import * as vscode from "vscode"; -import {CliScannerStrategy} from './scanner-strategy'; -import {Violation} from '../diagnostics'; -import {FileHandler} from '../fs-utils'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as semver from 'semver'; -import {SettingsManager} from "../settings"; -import {CliCommandExecutor, CommandOutput} from "../cli-commands"; -import {Workspace} from "../workspace"; - -type ResultsJson = { - runDir: string; - violations: Violation[]; -}; - -type RulesJson = { - rules: RuleDescription[]; -} - -type RuleDescription = { - name: string, - description: string, - engine: string, - severity: number, - tags: string[], - resources: string[] -} - -export class CliScannerV5Strategy implements CliScannerStrategy { - private readonly version: semver.SemVer; - private readonly cliCommandExecutor: CliCommandExecutor; - private readonly settingsManager: SettingsManager; - private readonly fileHandler: FileHandler; - - private ruleDescriptionMap?: Map; - - public constructor(version: semver.SemVer, cliCommandExecutor: CliCommandExecutor, settingsManager: SettingsManager, fileHandler: FileHandler) { - this.version = version; - this.cliCommandExecutor = cliCommandExecutor; - this.settingsManager = settingsManager; - this.fileHandler = fileHandler; - } - - public getScannerName(): Promise { - return Promise.resolve(`code-analyzer@${this.version.toString()} via CLI`); - } - - public async getRuleDescriptionFor(engineName: string, ruleName: string): Promise { - return (await this.getRuleDescriptionMap()).get(`${engineName}:${ruleName}`) || ''; - } - - private async getRuleDescriptionMap(): Promise> { - if (this.ruleDescriptionMap === undefined) { - if (semver.gte(this.version, '5.0.0-beta.3')) { - this.ruleDescriptionMap = await this.createRuleDescriptionMap(); - } else { - this.ruleDescriptionMap = new Map(); - } - } - return this.ruleDescriptionMap; - } - - public async scan(workspace: Workspace): Promise { - const ruleSelector: string = this.settingsManager.getCodeAnalyzerRuleSelectors(); - const configFile: string = this.settingsManager.getCodeAnalyzerConfigFile(); - - const args: string[] = ['code-analyzer', 'run']; - - if (semver.gte(this.version, '5.0.0')) { - workspace.getRawWorkspacePaths().forEach(p => args.push('-w', p)); - workspace.getRawTargetPaths().forEach(p => args.push('-t', p)); - } else { - // Before 5.0.0 the --target flag did not exist, so we just make the workspace equal to the target paths - workspace.getRawTargetPaths().forEach(p => args.push('-w', p)); - } - - if (ruleSelector) { - args.push('-r', ruleSelector); - } - if (configFile) { - args.push('-c', configFile); - } - - const outputFile: string = await this.fileHandler.createTempFile('.json'); - args.push('-f', outputFile); - - const commandOutput: CommandOutput = await this.cliCommandExecutor.exec('sf', args, {logLevel: vscode.LogLevel.Debug}); - if (commandOutput.exitCode !== 0) { - throw new Error(commandOutput.stderr); - } - - const resultsJsonStr: string = await fs.promises.readFile(outputFile, 'utf-8'); - const resultsJson: ResultsJson = JSON.parse(resultsJsonStr) as ResultsJson; - return this.processResults(resultsJson); - } - - private processResults(resultsJson: ResultsJson): Violation[] { - const processedViolations: Violation[] = []; - for (const violation of resultsJson.violations) { - for (const location of violation.locations) { - // If the path isn't already absolute, it needs to be made absolute. - if (location.file && path.resolve(location.file).toLowerCase() !== location.file.toLowerCase()) { - // Relative paths are relative to the RunDir results property. - location.file = path.join(resultsJson.runDir, location.file); - } - } - processedViolations.push(violation); - } - return processedViolations; - } - - private async createRuleDescriptionMap(): Promise> { - const outputFile: string = await this.fileHandler.createTempFile('.json'); - const commandOutput: CommandOutput = await this.cliCommandExecutor.exec('sf', ['code-analyzer', 'rules', '-r', 'all', '-f', outputFile]); - if (commandOutput.exitCode !== 0) { - throw new Error(commandOutput.stderr); - } - const rulesJsonStr: string = await fs.promises.readFile(outputFile, 'utf-8'); - const rulesOutput: RulesJson = JSON.parse(rulesJsonStr) as RulesJson; - - const ruleDescriptionMap: Map = new Map(); - for (const ruleDescription of rulesOutput.rules) { - ruleDescriptionMap.set(`${ruleDescription.engine}:${ruleDescription.name}`, ruleDescription.description); - } - return ruleDescriptionMap; - } -} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts deleted file mode 100644 index 593144fb..00000000 --- a/src/lib/scanner.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as vscode from 'vscode'; -import {SettingsManager} from './settings'; -import {V4ExecutionResult} from './scanner-strategies/v4-scanner'; -import * as Constants from './constants'; -import {CliCommandExecutor, CommandOutput} from "./cli-commands"; - -/** - * Class for interacting with the {@code @salesforce/sfdx-scanner} plug-in. - */ -export class ScanRunner { // TODO: I look forward to removing this once V4 goes away... but if it takes a long time for that to happen then we should consider moving all this DFA stuff inside of the v4-scanner.ts class instead. - private readonly settingsManager: SettingsManager; - private readonly cliCommandExecutor: CliCommandExecutor; - - public constructor(settingsManager: SettingsManager, cliCommandExecutor: CliCommandExecutor) { - this.settingsManager = settingsManager; - this.cliCommandExecutor = cliCommandExecutor; - } - - /** - * Run the DFA rules against the specified targets - * @param targets The targets for the scan. At this time, these must be method-level targets - * formatted as {@code path/to/file.cls#someMethod}. - * @param projectDir The directory containing all files in the project to be scanned. - * @returns The HTML-formatted scan results, or an empty string if no violations were found. - */ - public async runDfa(targets: string[], projectDir: string, context: vscode.ExtensionContext, cacheFilePath?: string): Promise { - // Create the arg array. - const args: string[] = this.createDfaArgArray(targets, projectDir, cacheFilePath); - - // Invoke the scanner. - const executionResult: V4ExecutionResult = await this.invokeDfaAnalyzer(args, context); - - // Process the results. - return this.processDfaResults(executionResult); - } - - /** - * Creates the arguments for an execution of {@code sf scanner run dfa}, for use in a child process. - * @param targets The files/methods to be targeted. - * @param projectDir The root of the project to be scanned. - */ - private createDfaArgArray(targets: string[], projectDir: string, cacheFilePath?: string): string[] { - const args: string[] = [ - 'scanner', 'run', 'dfa', - `--projectdir`, projectDir, - // NOTE: For now, we're using HTML output since it's the easiest to display to the user. - // This is exceedingly likely to change as we refine and polish the extension. - `--format`, `html`, - // NOTE: Using `--json` gives us easily-processed results, but denies us access to some - // elements of logging, most notably the informative progress reporting provided - // by the engine's spinner. This was deemed an acceptable trade-off during initial - // implementation, but we may wish to rethink this in the future as we polish things. - `--json` - ]; - - if (targets && targets.filter(target => target != null).length > 0) { - args.push('--target', `${targets.join(',')}`); - } - - if (cacheFilePath) { - args.push('--cachepath', cacheFilePath); - args.push('--enablecaching'); - } - - // There are a number of custom settings that we need to check too. - // First we should check whether warning violations are disabled. - if (this.settingsManager.getGraphEngineDisableWarningViolations()) { - args.push('--rule-disable-warning-violation'); - } - // Then we should check whether a custom timeout was specified. - const threadTimeout: number = this.settingsManager.getGraphEngineThreadTimeout(); - if (threadTimeout != null) { - args.push('--rule-thread-timeout', `${threadTimeout}`); - } - // Then we should check whether a custom path expansion limit is set. - const pathExpansionLimit: number = this.settingsManager.getGraphEnginePathExpansionLimit(); - if (pathExpansionLimit != null) { - args.push('--pathexplimit', `${pathExpansionLimit}`); - } - // Then we should check whether custom JVM args were specified. - const jvmArgs: string = this.settingsManager.getGraphEngineJvmArgs(); - if (jvmArgs) { - args.push('--sfgejvmargs', jvmArgs); - } - // NOTE: We don't check custom threadcount because we can only run against one entrypoint. - // If we ever add multi-entrypoint scanning in VSCode, we'll also need a setting for - // threadcount. - // TODO: Once RemoveUnusedMethod is made less noisy, add a setting for enabling Pilot rules. - return args; - } - - /** - * Uses the provided arguments to run a Salesforce Code Analyzer command. - * @param args The arguments to be supplied - * @param context - */ - private async invokeDfaAnalyzer(args: string[], context: vscode.ExtensionContext): Promise { - const commandOutput: CommandOutput = await this.cliCommandExecutor.exec('sf', args, { - pidHandler: (pid: number | undefined) => { - void context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, pid); - }, - logLevel: vscode.LogLevel.Debug - }); - void context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - // No matter what, stdout will be an execution result. - return JSON.parse(commandOutput.stdout) as V4ExecutionResult; - } - - /** - * - * @param executionResult The results from a scan - * @returns The HTML-formatted scan results, or an empty string. - * @throws If {@code executionResult.result} is not a string. - * @throws If {@code executionResult.warnings} contains any warnings about methods not being found. - * @throws if {@code executionResult.status} is non-zero. - */ - private processDfaResults(executionResult: V4ExecutionResult): string { - // 0 is the status code indicating a successful analysis. - if (executionResult.status === 0) { - // Since we're using HTML format, the results should always be a string. - // Enforce this assumption. - if (typeof executionResult.result !== 'string') { - // Hardcoding this message should be fine, because it should only ever - // appear in response to developer error, not user error. - throw new Error('Output should always be a string.'); - } - - // Before we do anything else, check our warnings, since we're escalating - // some of them to errors. - // NOTE: This section should be considered tentative. In addition to being - // generally inelegant, it's not great practice to key off specific - // messages in this fashion. - if (executionResult.warnings?.length > 0) { - for (const warning of executionResult.warnings) { - // Since (for now) DFA only runs on a single method, - // if we couldn't find that method, then that's a critical - // error even though DFA itself just considered it a warning. - if (warning.toLowerCase().startsWith('no methods in file ')) { - throw new Error(warning); - } - } - } - - const result: string = executionResult.result; - - if (result.startsWith("<]/g - -export function stripAnsi(str: string): string { - return str.replace(ANSI_REGEX, ''); -} diff --git a/src/lib/targeting.ts b/src/lib/targeting.ts index 9fee5a34..160b04ea 100644 --- a/src/lib/targeting.ts +++ b/src/lib/targeting.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import {glob} from 'glob'; import {FileHandlerImpl} from './fs-utils'; -import {ApexLsp, GenericSymbol} from './apex-lsp'; import {messages} from './messages'; /** @@ -41,88 +40,6 @@ export async function getFilesFromSelection(selections: vscode.Uri[]): Promise { - // Get the editor. - const activeEditor: vscode.TextEditor = vscode.window.activeTextEditor; - // If there's nothing open in the editor, we can't do anything. So just throw an error. - if (!activeEditor) { - throw new Error(messages.targeting.error.noFileSelected); - } - - // Get the document in the editor, and the cursor's position within it. - const textDocument: vscode.TextDocument = activeEditor.document; - const cursorPosition: vscode.Position = activeEditor.selection.active; - - // The filename-portion of the target string needs to be Unix-formatted, - // otherwise it will parse as a glob and kill the process. - const fileName: string = textDocument.fileName.replace(/\\/g, '/'); - - // If the Apex Language Server is available, we can use it to derive much more robust - // targeting information than we can independently. - const symbols: GenericSymbol[] = await ApexLsp.getSymbols(textDocument.uri); - if (symbols && symbols.length > 0) { - const nearestMethodSymbol: GenericSymbol = getNearestMethodSymbol(symbols, cursorPosition); - // If we couldn't find a method, throw an error. - if (!nearestMethodSymbol) { - throw new Error(messages.targeting.error.noMethodIdentified); - } - // The symbol's name property is the method signature, so we want to lop off everything - // after the first open-paren. - const methodSignature: string = nearestMethodSymbol.name; - return `${fileName}#${methodSignature.substring(0, methodSignature.indexOf('('))}`; - } else { - // Without the Apex Language Server, we'll take the quick-and-dirty route - // of just identifying the exact word the user selected, and assuming that's the name of a method. - vscode.window.showWarningMessage(messages.targeting.warnings.apexLspUnavailable); - const wordRange: vscode.Range = textDocument.getWordRangeAtPosition(cursorPosition); - return `${fileName}#${textDocument.getText(wordRange)}`; - } -} - -/** - * Identifies the method definition symbol that most closely precedes the cursor's current position. - * @param symbols Symbols returned via the Apex Language Server - * @param cursorPosition The current location of the cursor - * @returns - */ -function getNearestMethodSymbol(symbols: GenericSymbol[], cursorPosition: vscode.Position): GenericSymbol { - let nearestMethodSymbol: GenericSymbol = null; - let nearestMethodPosition: vscode.Position = null; - for (const symbol of symbols) { - // Skip symbols for non-methods. - if (symbol.kind !== vscode.SymbolKind.Method) { - continue; - } - // Get this method symbol's start line. - const symbolStartPosition: vscode.Position = isDocumentSymbol(symbol) - ? symbol.range.start - : symbol.location.range.start; - - // If this method symbol is defined after the cursor's current line, skip it. - // KNOWN BUG: If multiple methods are defined on the same line as the cursor, - // the latest one is used regardless of the cursor's location. - // Deemed acceptable, because you shouldn't define multiple methods per line. - if (symbolStartPosition.line > cursorPosition.line) { - continue; - } - - // Compare this method to the current nearest, and keep the later one. - if (!nearestMethodPosition || nearestMethodPosition.isBefore(symbolStartPosition)) { - nearestMethodSymbol = symbol; - nearestMethodPosition = symbolStartPosition; - } - } - return nearestMethodSymbol; -} - /** * Get the project containing the specified file. */ @@ -137,10 +54,3 @@ export function getProjectDir(targetFile?: string): string | undefined { const uri = vscode.Uri.file(targetFile); return vscode.workspace.getWorkspaceFolder(uri).uri.fsPath; } - -/** - * Type-guard for {@link vscode.DocumentSymbol}. - */ -function isDocumentSymbol(o: GenericSymbol): o is vscode.DocumentSymbol { - return 'range' in o; -} diff --git a/src/test/legacy/apex-lsp.test.ts b/src/test/legacy/apex-lsp.test.ts deleted file mode 100644 index 6c2fefe1..00000000 --- a/src/test/legacy/apex-lsp.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2024, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as Sinon from 'sinon'; -import {expect} from 'chai'; -import * as vscode from 'vscode'; -import { ApexLsp } from '../../lib/apex-lsp'; - -suite('ScanRunner', () => { - let executeCommandStub: Sinon.SinonStub; - - setup(() => { - executeCommandStub = Sinon.stub(vscode.commands, 'executeCommand'); - }); - - teardown(() => { - executeCommandStub.restore(); - }); - - test('Should call vscode.executeDocumentSymbolProvider with the correct documentUri and return the symbols', async () => { - const dummyRange: vscode.Range = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)); - const documentUri = vscode.Uri.file('test.cls'); - const childSymbol: vscode.DocumentSymbol = new vscode.DocumentSymbol( - 'MethodName', - 'some Method', - vscode.SymbolKind.Method, - dummyRange, - dummyRange); - - const parentSymbol: vscode.DocumentSymbol = new vscode.DocumentSymbol( - 'ClassName', - 'Name of Class', - vscode.SymbolKind.Class, - dummyRange, - dummyRange - ); - parentSymbol.children = [childSymbol]; - - const symbols: vscode.DocumentSymbol[] = [parentSymbol]; - - executeCommandStub.resolves(symbols); - - const result = await ApexLsp.getSymbols(documentUri); - - expect(executeCommandStub.calledOnceWith('vscode.executeDocumentSymbolProvider', documentUri)).to.equal(true); - - expect(result).to.deep.equal([parentSymbol, childSymbol]); // Should be flat - }); -}); diff --git a/src/test/legacy/deltarun/delta-run-service.test.ts b/src/test/legacy/deltarun/delta-run-service.test.ts deleted file mode 100644 index a3ca8c30..00000000 --- a/src/test/legacy/deltarun/delta-run-service.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2024, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {expect} from 'chai'; -import * as Sinon from 'sinon'; -import proxyquire from 'proxyquire'; - -suite('Delta Run Test Suite', () => { - suite('#getDeltaRunTarget', () => { - let readFileSyncStub: Sinon.SinonStub; - let getDeltaRunTarget: (sfgecachepath: string, savedFilesCache :Set) => void; - - // Set up stubs and mock the fs module - setup(() => { - readFileSyncStub = Sinon.stub(); - - // Load the module with the mocked fs dependency - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call - const mockedModule = proxyquire('../../../lib/deltarun/delta-run-service', { - fs: { - readFileSync: readFileSyncStub - } - }); - - // Get the function from the module - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - getDeltaRunTarget = mockedModule.getDeltaRunTarget; - }); - - teardown(() => { - Sinon.restore(); - }); - - test('Returns matching entries when files in cache match JSON data', () => { - // Setup the mock return value for readFileSync - const sfgecachepath = '/path/to/sfgecache.json'; - const savedFilesCache = new Set([ - '/some/user/path/HelloWorld.cls' - ]); - - const jsonData = `{ - "data": [ - { - "entries": ["/some/user/path/HelloWorld.cls#getProducts", "/some/user/path/HelloWorld.cls#getSimilarProducts"], - "filename": "/some/user/path/HelloWorld.cls" - } - ] - }`; - - readFileSyncStub.withArgs(sfgecachepath, 'utf-8').returns(jsonData); - - // Test - const result = getDeltaRunTarget(sfgecachepath, savedFilesCache); - - // Assertions - expect(result).to.deep.equal([ - '/some/user/path/HelloWorld.cls#getProducts', - '/some/user/path/HelloWorld.cls#getSimilarProducts' - ]); - - Sinon.assert.calledOnce(readFileSyncStub); - }); - - test('Returns an empty array when no matching files are found in cache', () => { - // ===== SETUP ===== - const sfgecachepath = '/path/to/sfgecache.json'; - const savedFilesCache = new Set([ - '/some/user/path/HelloWorld.cls' - ]); - - const jsonData = `{ - "data": [ - { - "filename": "/some/user/path/NotHelloWorld.cls", - "entries": ["/some/user/path/NotHelloWorld.cls#getProducts"] - } - ] - }`; - - // Stub the file read to return the JSON data - readFileSyncStub.withArgs(sfgecachepath, 'utf-8').returns(jsonData); - - // ===== TEST ===== - const result = getDeltaRunTarget(sfgecachepath, savedFilesCache); - - // ===== ASSERTIONS ===== - expect(result).to.deep.equal([]); - - Sinon.assert.calledOnce(readFileSyncStub); - }); - }); -}); diff --git a/src/test/legacy/extension.test.ts b/src/test/legacy/extension.test.ts index ce14a327..8b557cba 100644 --- a/src/test/legacy/extension.test.ts +++ b/src/test/legacy/extension.test.ts @@ -33,7 +33,8 @@ import { } from "../unit/stubs"; import {Workspace} from "../../lib/workspace"; -suite('Extension Test Suite', () => { +suite('Extension Test Suite', function () { + this.timeout(60000); // Global timeout for all tests in this suite vscode.window.showInformationMessage('Start all tests.'); // Note: Because this is a mocha test, __dirname here is actually the location of the js file in the out/test folder. const codeFixturesPath: string = path.resolve(__dirname, '..', '..', '..', 'src', 'test', 'code-fixtures'); @@ -51,9 +52,6 @@ suite('Extension Test Suite', () => { for (const [uri, diagnostics] of diagnosticsArrays) { expect(diagnostics, `${uri.toString()} should start without diagnostics`).to.be.empty; } - // Set custom settings - const configuration = vscode.workspace.getConfiguration(); - configuration.update('codeAnalyzer.scanner.engines', 'pmd,retire-js,eslint-lwc', vscode.ConfigurationTarget.Global); }); teardown(async () => { @@ -98,7 +96,7 @@ suite('Extension Test Suite', () => { } } - test('Adds proper diagnostics when running with v5', async function() { + test('Adds proper diagnostics', async function() { this.timeout(90000); await runTest(); }); @@ -130,19 +128,12 @@ suite('Extension Test Suite', () => { } } - test('Adds proper diagnostics when running with v5', async function() { + test('Adds proper diagnostics', async function() { this.timeout(90000); await runTest(); }); }); - suite('One folder selected', () => { - - test('Adds proper diagnostics when running with v5', async function() { - // TODO: WRITE THIS TEST - }); - }); - suite('Multiple files selected', () => { // Get the URIs for two separate files. const targetUri1: vscode.Uri = vscode.Uri.file(path.join(codeFixturesPath, 'folder a', 'MyClassA1.cls')); @@ -172,7 +163,7 @@ suite('Extension Test Suite', () => { } } - test('Adds proper diagnostics when running with v5', async function() { + test('Adds proper diagnostics', async function() { this.timeout(90000); await runTest(); }); @@ -181,7 +172,7 @@ suite('Extension Test Suite', () => { }); - suite('#_runAndDisplay()', () => { + suite('#_runAndDisplay()', function () { const ext: vscode.Extension = vscode.extensions.getExtension('salesforce.sfdx-code-analyzer-vscode'); let stubTelemetryService: StubTelemetryService; let codeAnalyzerRunAction: CodeAnalyzerRunAction; diff --git a/src/test/legacy/scanner.test.ts b/src/test/legacy/scanner.test.ts deleted file mode 100644 index 4d1d1f68..00000000 --- a/src/test/legacy/scanner.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions */ // TODO: Need to update these old tests... many of the chair assertions are not being used correctly causing eslint errors. -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as path from 'path'; -import {expect} from 'chai'; -import {SettingsManager} from '../../lib/settings'; -import {ScanRunner} from '../../lib/scanner'; -import * as vscode from 'vscode'; -import * as Constants from '../../lib/constants'; - -import {V4ExecutionResult} from '../../lib/scanner-strategies/v4-scanner'; -import {SFCAExtensionData} from "../../extension"; -import {CliCommandExecutorImpl} from "../../lib/cli-commands"; -import {SpyLogger} from "./test-utils"; - -suite('ScanRunner', () => { - - suite('#createDfaArgArray()', () => { - // Create a list of fake targets to use in our tests. - const targets: string[] = [ - 'these', - 'are', - 'all', - 'dummy', - 'targets' - ]; - // Create a fake projectdir value for our tests too. - const projectDir: string = path.join('this', 'path', 'does', 'not', 'matter'); - function invokeTestedMethod(settingsManager: StubSettingsManager): string[] { - // ===== SETUP ===== - // Create a scan runner. - const scanner: ScanRunner = new ScanRunner(settingsManager, new CliCommandExecutorImpl(new SpyLogger())); - - // ===== TEST ===== - // Use the scan runner on our target list to create and return our arg array. - - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - const args: string[] = (scanner as any).createDfaArgArray(targets, projectDir); - - // ===== ASSERTIONS ===== - // Perform the validations common to all cases. - expect(args).to.have.length.of.at.least(10, 'Wrong number of args'); - expect(args[0]).to.equal('scanner', 'Wrong arg'); - expect(args[1]).to.equal('run', 'Wrong arg'); - expect(args[2]).to.equal('dfa', 'Wrong arg'); - expect(args[3]).to.equal('--projectdir', 'Wrong arg'); - expect(args[4]).to.equal(projectDir, 'Wrong arg'); - expect(args[5]).to.equal('--format', 'Wrong arg'); - expect(args[6]).to.equal('html', 'Wrong arg'); - expect(args[7]).to.equal('--json', 'Wrong arg'); - expect(args[8]).to.equal('--target', 'Wrong arg'); - expect(args[9]).to.equal(targets.join(','), 'Wrong arg'); - - - return args; - } - - suite('Simple cases', () => { - - test('Creates array-ified sfdx-scanner dfa command', () => { - // ===== SETUP ===== - // Stub out all the settings methods to return null/false values. - const settingsManager: StubSettingsManager = new StubSettingsManager(); - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEnginePathExpansionLimit(null); - settingsManager.setGraphEngineJvmArgs(null); - - // ===== TEST ===== - // Call the test method helper. - const args: string[] = invokeTestedMethod(settingsManager); - - // ===== ASSERTIONS ===== - // Assert we got the right number of args. Everything else has been checked already. - expect(args).to.have.lengthOf(10, 'Wrong number of args'); - }); - }); - - suite('Settings values', () => { - - test('Ignore target when it is empty', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEnginePathExpansionLimit(null); - settingsManager.setGraphEngineJvmArgs(null); - const emptyTargets = []; - - // ===== TEST ===== - // Call the test method helper. - const scanner: ScanRunner = new ScanRunner(settingsManager, new CliCommandExecutorImpl(new SpyLogger())); - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - const args: string[] = (scanner as any).createDfaArgArray(emptyTargets, projectDir); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.not.include('--target', '--target should be ignored when empty'); - }); - - test('Ignore target when it contains only null entries', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEnginePathExpansionLimit(null); - settingsManager.setGraphEngineJvmArgs(null); - const emptyTargets = [null]; - - // ===== TEST ===== - // Call the test method helper. - const scanner: ScanRunner = new ScanRunner(settingsManager, new CliCommandExecutorImpl(new SpyLogger())); - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - const args: string[] = (scanner as any).createDfaArgArray(emptyTargets, projectDir); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.not.include('--target', '--target should be ignored when it contains null entry'); - }); - - test('Disable Warning Violations', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - // Stub the Disable Warning Violations method to return true. - settingsManager.setGraphEngineDisableWarningViolations(true); - // Stub out the other settings methods to return null/false values. - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEnginePathExpansionLimit(null); - settingsManager.setGraphEngineJvmArgs(null); - - // ===== TEST ===== - // Call the test method helper. - const args: string[] = invokeTestedMethod(settingsManager); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.have.lengthOf(11, 'Wrong number of args'); - expect(args[10]).to.equal('--rule-disable-warning-violation', 'Wrong arg'); - }); - - test('Thread Timeout', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - // Stub out the Thread Timeout method to return some unusual number. - const timeout: number = 234123; - settingsManager.setGraphEngineThreadTimeout(timeout); - // Stub out the other settings methods to return null/false values. - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEnginePathExpansionLimit(null); - settingsManager.setGraphEngineJvmArgs(null); - - // ===== TEST ===== - // Call the test method helper. - const args: string[] = invokeTestedMethod(settingsManager); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.have.lengthOf(12, 'Wrong number of args'); - expect(args[10]).to.equal('--rule-thread-timeout', 'Wrong arg'); - expect(args[11]).to.equal(`${timeout}`, 'Wrong arg'); - }); - - test('Path Expansion Limit', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - // Stub out the Path Expansion Limit method to return some unusual number. - const limit: number = 38832; - settingsManager.setGraphEnginePathExpansionLimit(limit); - // Stub out the other settings methods to return null/false values. - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEngineJvmArgs(null); - - // ===== TEST ===== - // Call the test method helper. - const args: string[] = invokeTestedMethod(settingsManager); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.have.lengthOf(12, 'Wrong number of args'); - expect(args[10]).to.equal('--pathexplimit', 'Wrong arg'); - expect(args[11]).to.equal(`${limit}`, 'Wrong arg'); - }); - - test('JVM Args', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - // Stub out the JVM Args method to return some non-standard value. - const jvmArgs = '-Xmx25g'; - settingsManager.setGraphEngineJvmArgs(jvmArgs); - // Stub out the other settings methods to return null/false values. - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEnginePathExpansionLimit(null); - - // ===== TEST ===== - // Call the test method helper. - const args: string[] = invokeTestedMethod(settingsManager); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.have.lengthOf(12, 'Wrong number of args'); - expect(args[10]).to.equal('--sfgejvmargs', 'Wrong arg'); - expect(args[11]).to.equal(jvmArgs, 'Wrong arg'); - }); - - test('Enable caching and include cache path', () => { - // ===== SETUP ===== - const settingsManager: StubSettingsManager = new StubSettingsManager(); - settingsManager.setGraphEngineDisableWarningViolations(false); - settingsManager.setGraphEngineThreadTimeout(null); - settingsManager.setGraphEnginePathExpansionLimit(null); - settingsManager.setGraphEngineJvmArgs(null); - const emptyTargets = []; - - // ===== TEST ===== - // Call the test method helper. - const scanner: ScanRunner = new ScanRunner(settingsManager, new CliCommandExecutorImpl(new SpyLogger())); - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way?c - const args: string[] = (scanner as any).createDfaArgArray(emptyTargets, projectDir, 'some/path/file.json'); - - // ===== ASSERTIONS ===== - // Verify that the right arguments were created. - expect(args).to.have.lengthOf(11, 'Wrong number of args'); - expect(args[8]).to.equal('--cachepath', 'Wrong arg'); - expect(args[9]).to.equal('some/path/file.json', 'Wrong arg'); - expect(args[10]).to.equal('--enablecaching', 'Wrong arg'); - }); - }); - }); - - /** - * NOTE: This entire section should be considered temporary. - * The current implementation of DFA support is merely tentative, - * and extremely likely to change as we move closer to going GA. - */ - suite('#processDfaResults()', () => { - test('Returns HTML-formatted violations after successful scan', () => { - // ===== SETUP ===== - // Create spoofed result with some HTML output. - const spoofedOutput: V4ExecutionResult = { - status: 0, - result: `` - }; - - // ===== TEST ===== - // Feed the results into the processor. - const scanner = new ScanRunner(new StubSettingsManager(), new CliCommandExecutorImpl(new SpyLogger())); - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - const processedResults: string = (scanner as any).processDfaResults(spoofedOutput); - - // ===== ASSERTIONS ===== - // Verify that the html output was returned unchanged. - expect(processedResults).to.equal(spoofedOutput.result, 'Wrong results returned'); - }); - - test('Returns empty string after violation-less scan', () => { - // ===== SETUP ===== - // Create spoofed results without any violations. - const spoofedOutput: V4ExecutionResult = { - status: 0, - // TODO: This may change with time. - result: "Executed engines: sfge. No rule violations found." - }; - - // ===== TEST ===== - // Feed the results into the processor. - const scanner = new ScanRunner(new StubSettingsManager(), new CliCommandExecutorImpl(new SpyLogger())); - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - const processedResults: string = (scanner as any).processDfaResults(spoofedOutput); - - // ===== ASSERTIONS ===== - // Verify that an empty string was returned. - expect(processedResults).to.equal("", "Expected empty string"); - }); - - test('Escalates method-not-found warning to error', () => { - // ===== SETUP ===== - // Create spoofed output including a warning about a targeted method - // not being found. - const spoofedOutput: V4ExecutionResult = { - status: 0, - result: "Executed engines: sfge. No rule violations found.", - warnings: [ - "We're continually improving Salesforce Code Analyzer. Tell us what you think! Give feedback at https://research.net/r/SalesforceCA", - "No methods in file /this/path/does/not/matter/MySourceFile.cls matched name #notARealMethod()" - ] - }; - - // ===== TEST ===== - // Feed the output into the processor, expecting the warning - // to be escalated to an error. - const scanner = new ScanRunner(new StubSettingsManager(), new CliCommandExecutorImpl(new SpyLogger())); - let err: Error = null; - try { - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - (scanner as any).processDfaResults(spoofedOutput); - } catch (e) { - err = e as Error; - } - - // ===== ASSERTIONS ===== - expect(err).to.exist; - expect(err.message).to.equal(spoofedOutput.warnings[1]); - }); - - test('Throws error message from failed scan', () => { - // ===== SETUP ===== - // Create spoofed output indicating an error. - const spoofedOutput: V4ExecutionResult = { - status: 50, - message: "Some error occurred. OH NO!" - }; - - // ===== TEST ===== - // Feed the output into the processor, expecting an error. - const scanner = new ScanRunner(new StubSettingsManager(), new CliCommandExecutorImpl(new SpyLogger())); - let err: Error = null; - try { - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - (scanner as any).processDfaResults(spoofedOutput); - } catch (e) { - err = e as Error; - } - - // ===== ASSERTIONS ===== - expect(err).to.exist; - expect(err.message).to.equal(spoofedOutput.message); - }); - }); - - suite('#invokeDfaAnalyzer()', () => { - const ext: vscode.Extension = vscode.extensions.getExtension('salesforce.sfdx-code-analyzer-vscode'); - let context: vscode.ExtensionContext; - - suiteSetup(async function () { - this.timeout(10000); - // Activate the extension. - const extData: SFCAExtensionData = await ext.activate(); - context = extData.context; - }); - - test('Adds process Id to the cache', () => { - // ===== SETUP ===== - const args:string[] = ['scanner', 'run', 'dfa', '--target', 'doesNotMatter', '--json']; - const scanner = new ScanRunner(new StubSettingsManager(), new CliCommandExecutorImpl(new SpyLogger())); - void context.workspaceState.update(Constants.WORKSPACE_DFA_PROCESS, undefined); - - // ===== TEST ===== - /* eslint-disable-next-line */ // TODO: Wow - using "any" here to somehow get access to a private method. Why is this test written this way? - (scanner as any).invokeDfaAnalyzer(args, context); - - // ===== ASSERTIONS ===== - expect(context.workspaceState.get(Constants.WORKSPACE_DFA_PROCESS)).to.be.not.undefined; - }); - }); -}); - -class StubSettingsManager implements SettingsManager { - private graphEngineDisableWarningViolations: boolean = false; - private graphEngineThreadTimeout: number = 900000; - private graphEnginePathExpansionLimit: number = null; - private graphEngineJvmArgs: string = null; - - constructor() { - this.resetSettings(); - } - - getCodeAnalyzerConfigFile(): string | undefined { - throw new Error('Method not implemented.'); - } - getCodeAnalyzerRuleSelectors(): string | undefined { - throw new Error('Method not implemented.'); - } - - public resetSettings(): void { - this.graphEngineDisableWarningViolations = false; - this.graphEngineThreadTimeout = 900000; - this.graphEnginePathExpansionLimit = null; - this.graphEngineJvmArgs = null; - } - - getCodeAnalyzerV5Enabled(): boolean { - throw new Error('Method not implemented.'); - } - - getPmdCustomConfigFile(): string { - throw new Error('Method not implemented.'); - } - - setGraphEngineDisableWarningViolations(b: boolean): void { - this.graphEngineDisableWarningViolations = b; - } - - getGraphEngineDisableWarningViolations(): boolean { - return this.graphEngineDisableWarningViolations; - } - - setGraphEngineThreadTimeout(n: number): void { - this.graphEngineThreadTimeout = n; - } - - getGraphEngineThreadTimeout(): number { - return this.graphEngineThreadTimeout; - } - - setGraphEnginePathExpansionLimit(n: number): void { - this.graphEnginePathExpansionLimit = n; - } - - getGraphEnginePathExpansionLimit(): number { - return this.graphEnginePathExpansionLimit; - } - - setGraphEngineJvmArgs(s: string): void { - this.graphEngineJvmArgs = s; - } - - getGraphEngineJvmArgs(): string { - return this.graphEngineJvmArgs; - } - - getAnalyzeOnSave(): boolean { - throw new Error('Method not implemented.'); - } - - getAnalyzeOnOpen(): boolean { - throw new Error('Method not implemented.'); - } - - getEnginesToRun(): string { - throw new Error('Method not implemented.'); - } - - getNormalizeSeverityEnabled(): boolean { - throw new Error('Method not implemented.'); - } - - getRulesCategory(): string { - throw new Error('Method not implemented.'); - } - - getApexGuruEnabled(): boolean { - throw new Error('Method not implemented.'); - } - - getSfgePartialSfgeRunsEnabled(): boolean { - throw new Error('Method not implemented.'); - } - - getEditorCodeLensEnabled(): boolean { - throw new Error('Method not implemented.'); - } -} diff --git a/src/test/legacy/settings.test.ts b/src/test/legacy/settings.test.ts index 4710fd11..332052fb 100644 --- a/src/test/legacy/settings.test.ts +++ b/src/test/legacy/settings.test.ts @@ -21,81 +21,6 @@ suite('SettingsManager Test Suite', () => { Sinon.restore(); }); - test('getPmdCustomConfigFile should return the customConfigFile setting', () => { - // ===== SETUP ===== - const mockCustomConfigFile = 'config/path/to/customConfigFile'; - getConfigurationStub.withArgs('codeAnalyzer.pMD').returns({ - get: Sinon.stub().returns(mockCustomConfigFile) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getPmdCustomConfigFile(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockCustomConfigFile); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.pMD')).to.equal(true); - }); - - test('getGraphEngineDisableWarningViolations should return the disableWarningViolations setting', () => { - // ===== SETUP ===== - const mockDisableWarningViolations = true; - getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ - get: Sinon.stub().returns(mockDisableWarningViolations) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getGraphEngineDisableWarningViolations(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockDisableWarningViolations); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.equal(true); - }); - - test('getGraphEngineThreadTimeout should return the threadTimeout setting', () => { - // ===== SETUP ===== - const mockThreadTimeout = 30000; - getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ - get: Sinon.stub().returns(mockThreadTimeout) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getGraphEngineThreadTimeout(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockThreadTimeout); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.equal(true); - }); - - test('getGraphEnginePathExpansionLimit should return the pathExpansionLimit setting', () => { - // ===== SETUP ===== - const mockPathExpansionLimit = 100; - getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ - get: Sinon.stub().returns(mockPathExpansionLimit) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getGraphEnginePathExpansionLimit(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockPathExpansionLimit); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.equal(true); - }); - - test('getGraphEngineJvmArgs should return the jvmArgs setting', () => { - // ===== SETUP ===== - const mockJvmArgs = '-Xmx2048m'; - getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ - get: Sinon.stub().returns(mockJvmArgs) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getGraphEngineJvmArgs(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockJvmArgs); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.equal(true); - }); - test('getAnalyzeOnSave should return the analyzeOnSave enabled setting', () => { // ===== SETUP ===== const mockAnalyzeOnSaveEnabled = true; @@ -126,51 +51,6 @@ suite('SettingsManager Test Suite', () => { expect(getConfigurationStub.calledOnceWith('codeAnalyzer.analyzeOnOpen')).to.equal(true); }); - test('getEnginesToRun should return the engines setting', () => { - // ===== SETUP ===== - const mockEngines = 'engine1, engine2'; - getConfigurationStub.withArgs('codeAnalyzer.scanner').returns({ - get: Sinon.stub().returns(mockEngines) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getEnginesToRun(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockEngines); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.scanner')).to.equal(true); - }); - - test('getNormalizeSeverityEnabled should return the normalizeSeverity enabled setting', () => { - // ===== SETUP ===== - const mockNormalizeSeverityEnabled = true; - getConfigurationStub.withArgs('codeAnalyzer.normalizeSeverity').returns({ - get: Sinon.stub().returns(mockNormalizeSeverityEnabled) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getNormalizeSeverityEnabled(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockNormalizeSeverityEnabled); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.normalizeSeverity')).to.equal(true); - }); - - test('getRulesCategory should return the rules category setting', () => { - // ===== SETUP ===== - const mockRulesCategory = 'bestPractices'; - getConfigurationStub.withArgs('codeAnalyzer.rules').returns({ - get: Sinon.stub().returns(mockRulesCategory) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getRulesCategory(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockRulesCategory); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.rules')).to.equal(true); - }); - test('getApexGuruEnabled should return the apexGuru enabled setting', () => { // ===== SETUP ===== const mockAnalyzeOnSaveEnabled = true; @@ -185,19 +65,4 @@ suite('SettingsManager Test Suite', () => { expect(result).to.equal(mockAnalyzeOnSaveEnabled); expect(getConfigurationStub.calledOnceWith('codeAnalyzer.apexGuru')).to.equal(true); }); - - test('getSfgeDeltaRunsEnabled should return the delta runs enabled setting', () => { - // ===== SETUP ===== - const mockAnalyzeOnSaveEnabled = true; - getConfigurationStub.withArgs('codeAnalyzer.partialGraphEngineScans').returns({ - get: Sinon.stub().returns(mockAnalyzeOnSaveEnabled) - }); - - // ===== TEST ===== - const result = new SettingsManagerImpl().getSfgePartialSfgeRunsEnabled(); - - // ===== ASSERTIONS ===== - expect(result).to.equal(mockAnalyzeOnSaveEnabled); - expect(getConfigurationStub.calledOnceWith('codeAnalyzer.partialGraphEngineScans')).to.equal(true); - }); }); diff --git a/src/test/legacy/targeting.test.ts b/src/test/legacy/targeting.test.ts index 16aa1e84..d1614831 100644 --- a/src/test/legacy/targeting.test.ts +++ b/src/test/legacy/targeting.test.ts @@ -9,18 +9,12 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as Sinon from 'sinon'; import {expect} from 'chai'; -import {getSelectedMethod, getFilesFromSelection} from '../../lib/targeting'; -import {ApexLsp, GenericSymbol} from '../../lib/apex-lsp'; +import {getFilesFromSelection} from '../../lib/targeting'; suite('targeting.ts', () => { // Note: Because this is a mocha test, __dirname here is actually the location of the js file in the out/test folder. const codeFixturesPath: string = path.resolve(__dirname, '..', '..', '..', 'src', 'test', 'code-fixtures'); - function moveCursor(line: number, column: number): void { - const position = new vscode.Position(line, column); - vscode.window.activeTextEditor.selection = new vscode.Selection(position, position); - } - teardown(async () => { // Close all open editors after each test. await vscode.commands.executeCommand('workbench.action.closeAllEditors'); @@ -141,108 +135,4 @@ suite('targeting.ts', () => { expect(targets).to.have.lengthOf(0, 'Wrong nubmer of targets returned'); }); }); - - suite('#getSelectedMethod()', () => { - const openFilePath: string = path.join(codeFixturesPath, 'folder a', 'MyClassA1.cls'); - const openFileUri: vscode.Uri = vscode.Uri.file(openFilePath); - // We expect our path to be unix-ified, or else it'll parse as a glob and flunk - // the transaction. - const expectedFilePath = openFilePath.replace(/\\/g, '/'); - suite('When Apex LSP is available...', () => { - - setup(async () => { - // Open and display the document. - const doc: vscode.TextDocument = await vscode.workspace.openTextDocument(openFileUri); - await vscode.window.showTextDocument(doc); - // Declare a spoofed array of symbols like what we'd get from the Apex LSP, then - // use a stub to return it. - const symbols: GenericSymbol[] = [ - new vscode.SymbolInformation('MyClassA1', vscode.SymbolKind.Class, '', - new vscode.Location(openFileUri, new vscode.Range( - new vscode.Position(6, 26), new vscode.Position(6, 35) - )) - ), - new vscode.SymbolInformation('beep() : Boolean', vscode.SymbolKind.Method, '', - new vscode.Location(openFileUri, new vscode.Range( - new vscode.Position(7, 26), new vscode.Position(7, 30) - )) - ), - new vscode.SymbolInformation('boop() : Boolean', vscode.SymbolKind.Method, '', - new vscode.Location(openFileUri, new vscode.Range( - new vscode.Position(11, 26), new vscode.Position(11, 30) - )) - ), - new vscode.SymbolInformation('instanceBoop() : Boolean', vscode.SymbolKind.Method, '', - new vscode.Location(openFileUri, new vscode.Range( - new vscode.Position(15, 19), new vscode.Position(15, 31) - )) - ) - ]; - Sinon.stub(ApexLsp, 'getSymbols').resolves(symbols); - }); - - test('Returns nearest preceding method if locateable', async () => { - // ===== SETUP ===== - // Move the cursor to a line with a preceding method. - moveCursor(14, 0); - - // ===== TEST ===== - // Get the method currently selected. - const selectedMethod: string = await getSelectedMethod(); - - // ===== ASSERTIONS ===== - expect(selectedMethod).to.equal(`${expectedFilePath}#boop`, 'Wrong method identified'); - }); - - test('Throws error if no method can be found', async () => { - // ===== SETUP ===== - // Move the cursor to the beginning of the doc, where there's - // definitely no method. - moveCursor(6, 15); - - // ===== TEST ===== - // Attempt to get the method currently selected, expecting an error. - let err: Error = null; - try { - await getSelectedMethod(); - } catch (e) { - err = e as Error; - } - - // ===== ASSERTIONS ===== - // Verify we got the right error. - expect(err).to.not.be.null; - }); - }); - - suite('When Apex LSP is unavailable...', () => { - let warningSpy: Sinon.SinonSpy; - setup(() => { - // Simulate the Apex LSP being unavailable by stubbing the appropriate - // method to return `undefined`. - Sinon.stub(ApexLsp, 'getSymbols').resolves(undefined); - // Create a Spy so we can see what's going on with the warnings. - warningSpy = Sinon.spy(vscode.window, 'showWarningMessage') - }); - - test('Displays warning and returns current word', async () => { - // ===== SETUP ===== - // Open a file in the editor. - const doc: vscode.TextDocument = await vscode.workspace.openTextDocument(openFileUri); - await vscode.window.showTextDocument(doc); - // Move the cursor to the declaration of a method. - vscode.window.activeTextEditor.selection = new vscode.Selection(new vscode.Position(7, 29), new vscode.Position(7, 29)); - - // ===== TEST ===== - // Attempt to get the currently selected method. - const selectedMethod: string = await getSelectedMethod(); - - // ===== ASSERTIONS ===== - // Verify that a warning was displayed. - Sinon.assert.callCount(warningSpy, 1); - // Verify that the first word of the file was returned. - expect(selectedMethod).to.equal(`${expectedFilePath}#beep`, 'Wrong word returned'); - }); - }); - }); }); diff --git a/src/test/unit/lib/code-analyzer.test.ts b/src/test/unit/lib/code-analyzer.test.ts index 101b4764..30baed0b 100644 --- a/src/test/unit/lib/code-analyzer.test.ts +++ b/src/test/unit/lib/code-analyzer.test.ts @@ -23,8 +23,8 @@ describe('Tests for the CodeAnalyzerImpl class', () => { codeAnalyzer = new CodeAnalyzerImpl(cliCommandExecutor, settingsManager, display, fileHandler); }); - describe('v5 tests', () => { - describe('v5 tests for the validateEnvironment method', () => { + describe('Code Analyzer Tests', () => { + describe('tests for the validateEnvironment method', () => { it('When the Salesforce CLI is not installed, then error', async () => { cliCommandExecutor.isSfInstalledReturnValue = false; await expect(codeAnalyzer.validateEnvironment()).rejects.toThrow(messages.error.sfMissing); @@ -60,20 +60,26 @@ describe('Tests for the CodeAnalyzerImpl class', () => { }); }); - describe('v5 tests for the getScannerName method', () => { - it('Sanity check that getScannerName first calls validateEnvironment', async () => { + describe('tests for the getVersion method', () => { + it('When the Salesforce CLI is not installed, then error', async () => { cliCommandExecutor.isSfInstalledReturnValue = false; - await expect(codeAnalyzer.getScannerName()).rejects.toThrow(messages.error.sfMissing); + await expect(codeAnalyzer.getVersion()).rejects.toThrow(messages.error.sfMissing); }); - it('The name reflects the currently set v5 version', async () => { + it('When installed with at least the minimum recommended version, then no error and no warning', async () => { cliCommandExecutor.getSfCliPluginVersionReturnValue = new semver.SemVer('5.0.0-beta.3'); - const scannerName: string = await codeAnalyzer.getScannerName(); - expect(scannerName).toEqual('code-analyzer@5.0.0-beta.3 via CLI'); + const version: string = await codeAnalyzer.getVersion(); + expect(version).toEqual('5.0.0-beta.3'); + }); + + it('When installed with a version greater than the minimum recommended version, then no error and no warning', async () => { + cliCommandExecutor.getSfCliPluginVersionReturnValue = new semver.SemVer('5.3.0'); + const version: string = await codeAnalyzer.getVersion(); + expect(version).toEqual('5.3.0'); }); }); - describe('v5 tests for the scan method', () => { + describe('tests for the scan method', () => { const vscodeWorkspace: stubs.StubVscodeWorkspace = new stubs.StubVscodeWorkspace(); const expectedViolation1: Violation = { @@ -124,7 +130,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { await expect(codeAnalyzer.scan(workspace)).rejects.toThrow(messages.error.sfMissing); }); - it('When running a scan with a beta version of v5, then confirm we call the cli and process the results correctly using only --workspace', async () => { + it('When running a scan with a beta version code-analyzer, then confirm we call the cli and process the results correctly using only --workspace', async () => { vscodeWorkspace.getWorkspaceFoldersReturnValue = ['/my/project']; cliCommandExecutor.getSfCliPluginVersionReturnValue = new semver.SemVer('5.0.0-beta.3'); @@ -218,7 +224,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { // when JSON file doesn't parse, etc }); - describe('v5 tests for the getRuleDescriptionFor method', () => { + describe('tests for the getRuleDescriptionFor method', () => { const prePopulatedRuleDescriptionJsonFile: string = path.join(TEST_DATA_DIR, 'sample-code-analyzer-rules-output.json'); it('Sanity check that getRuleDescriptionFor first calls validateEnvironment', async () => { diff --git a/src/test/unit/lib/settings.test.ts b/src/test/unit/lib/settings.test.ts index 01d49be1..b944dbaa 100644 --- a/src/test/unit/lib/settings.test.ts +++ b/src/test/unit/lib/settings.test.ts @@ -46,7 +46,7 @@ describe('Tests for the SettingsManagerImpl class ', () => { }); - describe('v5 Settings', () => { + describe('Configuration Settings', () => { it('should get configFile', () => { getMock.mockReturnValue('path/to/config'); expect(settingsManager.getCodeAnalyzerConfigFile()).toBe('path/to/config'); @@ -60,62 +60,6 @@ describe('Tests for the SettingsManagerImpl class ', () => { }); }); - describe('v4 Settings (Deprecated)', () => { - it('should get PMD custom config file', () => { - getMock.mockReturnValue('custom-config.xml'); - expect(settingsManager.getPmdCustomConfigFile()).toBe('custom-config.xml'); - expect(getMock).toHaveBeenCalledWith('customConfigFile'); - }); - - it('should get disableWarningViolations', () => { - getMock.mockReturnValue(true); - expect(settingsManager.getGraphEngineDisableWarningViolations()).toBe(true); - expect(getMock).toHaveBeenCalledWith('disableWarningViolations'); - }); - - it('should get threadTimeout', () => { - getMock.mockReturnValue(1234); - expect(settingsManager.getGraphEngineThreadTimeout()).toBe(1234); - expect(getMock).toHaveBeenCalledWith('threadTimeout'); - }); - - it('should get pathExpansionLimit', () => { - getMock.mockReturnValue(25); - expect(settingsManager.getGraphEnginePathExpansionLimit()).toBe(25); - expect(getMock).toHaveBeenCalledWith('pathExpansionLimit'); - }); - - it('should get jvmArgs', () => { - getMock.mockReturnValue('-Xmx1024m'); - expect(settingsManager.getGraphEngineJvmArgs()).toBe('-Xmx1024m'); - expect(getMock).toHaveBeenCalledWith('jvmArgs'); - }); - - it('should get enginesToRun', () => { - getMock.mockReturnValue('engine1,engine2'); - expect(settingsManager.getEnginesToRun()).toBe('engine1,engine2'); - expect(getMock).toHaveBeenCalledWith('engines'); - }); - - it('should get normalizeSeverityEnabled', () => { - getMock.mockReturnValue(true); - expect(settingsManager.getNormalizeSeverityEnabled()).toBe(true); - expect(getMock).toHaveBeenCalledWith('enabled'); - }); - - it('should get rulesCategory', () => { - getMock.mockReturnValue('Best Practices'); - expect(settingsManager.getRulesCategory()).toBe('Best Practices'); - expect(getMock).toHaveBeenCalledWith('category'); - }); - - it('should get partialSfgeRunsEnabled', () => { - getMock.mockReturnValue(true); - expect(settingsManager.getSfgePartialSfgeRunsEnabled()).toBe(true); - expect(getMock).toHaveBeenCalledWith('enabled'); - }); - }); - describe('Editor Settings', () => { it('should get codeLens setting', () => { getMock.mockReturnValue(true); diff --git a/src/test/unit/stubs.ts b/src/test/unit/stubs.ts index f3d90634..f70018f4 100644 --- a/src/test/unit/stubs.ts +++ b/src/test/unit/stubs.ts @@ -153,7 +153,7 @@ export class StubCodeAnalyzer implements CodeAnalyzer { getScannerNameReturnValue: string = 'dummyScannerName'; - getScannerName(): Promise { + getVersion(): Promise { return Promise.resolve(this.getScannerNameReturnValue); } @@ -173,7 +173,7 @@ export class ThrowingCodeAnalyzer implements CodeAnalyzer { throw new Error("Error from scan"); } - getScannerName(): Promise { + getVersion(): Promise { return Promise.resolve('someScannerName'); } @@ -255,7 +255,7 @@ export class StubSettingsManager implements SettingsManager { } // ================================================================================================================= - // ==== v5 Settings + // ==== Configuration Settings // ================================================================================================================= getCodeAnalyzerConfigFileReturnValue: string = ''; @@ -269,45 +269,6 @@ export class StubSettingsManager implements SettingsManager { return this.getCodeAnalyzerRuleSelectorsReturnValue; } - // ================================================================================================================= - // ==== v4 Settings (Deprecated) - // ================================================================================================================= - getPmdCustomConfigFile(): string { - throw new Error("Method not implemented."); - } - - getGraphEngineDisableWarningViolations(): boolean { - throw new Error("Method not implemented."); - } - - getGraphEngineThreadTimeout(): number { - throw new Error("Method not implemented."); - } - - getGraphEnginePathExpansionLimit(): number { - throw new Error("Method not implemented."); - } - - getGraphEngineJvmArgs(): string { - throw new Error("Method not implemented."); - } - - getEnginesToRun(): string { - throw new Error("Method not implemented."); - } - - getNormalizeSeverityEnabled(): boolean { - throw new Error("Method not implemented."); - } - - getRulesCategory(): string { - throw new Error("Method not implemented."); - } - - getSfgePartialSfgeRunsEnabled(): boolean { - throw new Error("Method not implemented."); - } - // ================================================================================================================= // ==== Other Settings that we may depend on // ================================================================================================================= From c8f394d7d23201a9ae63d0e5b17ea3117e77536c Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:39:52 -0400 Subject: [PATCH 08/17] FIX: @W-19160212@: Fix argument order of A4D QF action callback (#267) --- src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index 31c0dcbc..22f1363f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -261,7 +261,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { + registerCommand(A4DFixAction.COMMAND, async (diagnostic: CodeAnalyzerDiagnostic, document: vscode.TextDocument) => { await a4dFixAction.run(diagnostic, document); }); // Invoked by the "quick fix" buttons on A4D enabled diagnostics From f6333f0700a0eb7ac1806fbc43fc07b2e161e17f Mon Sep 17 00:00:00 2001 From: Randi Wilson Date: Fri, 8 Aug 2025 14:39:20 -0400 Subject: [PATCH 09/17] CHANGE: @W-17987318@ Add automatic retries to heartbeat and smoke tests (#268) --- .github/workflows/daily-smoke-test.yml | 27 +++++++++++++----- .github/workflows/production-heartbeat.yml | 32 ++++++++++++++++------ .github/workflows/retry.yml | 20 ++++++++++++++ 3 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/retry.yml diff --git a/.github/workflows/daily-smoke-test.yml b/.github/workflows/daily-smoke-test.yml index 1fa6da0b..b35aea5e 100644 --- a/.github/workflows/daily-smoke-test.yml +++ b/.github/workflows/daily-smoke-test.yml @@ -11,30 +11,43 @@ on: jobs: # Step 1: Build the tarballs so they can be installed locally. build-code-analyzer-tarball: - name: 'Build code analyzer tarball' + name: Build code analyzer tarball uses: ./.github/workflows/build-tarball.yml with: target-branch: 'dev' # Step 2: Actually run the tests. smoke-test: - name: 'Run smoke tests' + name: Run smoke tests needs: [build-code-analyzer-tarball] uses: ./.github/workflows/run-tests.yml with: use-tarballs: true tarball-suffix: 'dev' secrets: inherit - # Step 3: Build a VSIX artifact for use if needed. + # Step 3: Retry on failure after install timeout or flaky tests + retry-on-failure: + name: Retry on failure + runs-on: ubuntu-latest + needs: [build-code-analyzer-tarball, smoke-test] + if: failure() && fromJSON(github.run_attempt) < 3 + steps: + - name: Trigger retry workflow + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run retry.yml -F github_run_id=${{ github.run_id }} + # Step 4: Build a VSIX artifact for use if needed. create-vsix-artifact: name: 'Upload VSIX as artifact' uses: ./.github/workflows/create-vsix-artifact.yml secrets: inherit - # Step 4: Report any problems + # Step 5: Report any problems report-problems: - name: 'Report problems' + name: Report problems runs-on: ubuntu-latest needs: [build-code-analyzer-tarball, smoke-test, create-vsix-artifact] - if: ${{ failure() || cancelled() }} + if: failure() && fromJSON(github.run_attempt) >= 3 steps: - name: Report problems shell: bash @@ -42,7 +55,7 @@ jobs: RUN_LINK: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | ALERT_SEV="info" - ALERT_SUMMARY="Daily smoke test failed on ${{ runner.os }}" + ALERT_SUMMARY="Daily smoke test failed after 3 attempts on ${{ runner.os }}" generate_post_data() { cat <= 3 + steps: - name: Report problems - # There are problems if any step failed or was skipped. - # Note that the `join()` call omits null values, so if any steps were skipped, they won't have a corresponding - # value in the string. - if: ${{ failure() || cancelled() }} shell: bash env: # A link to this run, so the PagerDuty assignee can quickly get here. RUN_LINK: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - ALERT_SEV="info" - ALERT_SUMMARY="Production heartbeat script failed on ${{ runner.os }}" + ALERT_SUMMARY="Production heartbeat script failed after 3 attempts on ${{ runner.os }}" # Define a helper function to create our POST request's data, to sidestep issues with nested quotations. generate_post_data() { # This is known as a HereDoc, and it lets us declare multi-line input ending when the specified limit string, diff --git a/.github/workflows/retry.yml b/.github/workflows/retry.yml new file mode 100644 index 00000000..d5df29df --- /dev/null +++ b/.github/workflows/retry.yml @@ -0,0 +1,20 @@ +# This workflow is used in order to retry a failed workflow run. +name: Retry workflow +on: + workflow_dispatch: + inputs: + github_run_id: + required: true + description: "The ID of the workflow run to retry" +jobs: + Retry: + runs-on: ubuntu-latest + steps: + - name: Retry Github Action ${{ inputs.github_run_id }} + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + GH_DEBUG: api # Used for verbose output + run: | + gh run watch ${{ inputs.github_run_id }} > /dev/null 2>&1 + gh run rerun ${{ inputs.github_run_id }} --failed \ No newline at end of file From 3c85fd7b37a7120310e5f265d7c891ccfecd6d83 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:10:48 -0400 Subject: [PATCH 10/17] NEW: @W-19053461@: Add in the ability to see violation suggestions (#269) --- scripts/setup-jest.ts | 18 +- src/extension.ts | 20 ++ src/lib/apexguru/apex-guru-service.ts | 26 +-- src/lib/constants.ts | 4 + src/lib/diagnostics.ts | 31 +-- src/lib/messages.ts | 4 + .../violation-suggestions-hover-provider.ts | 69 ++++++ .../legacy/apexguru/apex-guru-service.test.ts | 2 +- src/test/legacy/test-utils.ts | 4 + ...olation-suggestions-hover-provider.test.ts | 196 ++++++++++++++++++ 10 files changed, 334 insertions(+), 40 deletions(-) create mode 100644 src/lib/violation-suggestions-hover-provider.ts create mode 100644 src/test/unit/lib/violation-suggestions-hover-provider.test.ts diff --git a/scripts/setup-jest.ts b/scripts/setup-jest.ts index 03aaa98d..ea36e9d4 100644 --- a/scripts/setup-jest.ts +++ b/scripts/setup-jest.ts @@ -6,7 +6,21 @@ import * as jestMockVscode from 'jest-mock-vscode'; function getMockVSCode() { - // Using a 3rd party library to help create the mocks instead of creating them all manually - return jestMockVscode.createVSCodeMock(jest); + return { + // Using a 3rd party library to help create the mocks instead of creating them all manually + ... jestMockVscode.createVSCodeMock(jest), + + + // Defining Hover since it is missing from jest-mock-vscode + "Hover": class Hover { + public readonly contents: any[]; + public readonly range?: any; + + constructor(contents: any | any[], range?: any) { + this.contents = Array.isArray(contents) ? contents : [contents]; + this.range = range; + } + } + }; } jest.mock('vscode', getMockVSCode, {virtual: true}) diff --git a/src/extension.ts b/src/extension.ts index 22f1363f..c3227b55 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,6 +34,7 @@ import {Workspace} from "./lib/workspace"; import {PMDSupressionsCodeActionProvider} from './lib/pmd/pmd-suppressions-code-action-provider'; import {ApplyViolationFixesActionProvider} from './lib/apply-violation-fixes-action-provider'; import {ApplyViolationFixesAction} from './lib/apply-violation-fixes-action'; +import { ViolationSuggestionsHoverProvider } from './lib/violation-suggestions-hover-provider'; // Object to hold the state of our extension for a specific activation context, to be returned by our activate function @@ -63,6 +64,9 @@ export async function activate(context: vscode.ExtensionContext): Promise { context.subscriptions.push(vscode.languages.registerCodeActionsProvider(selector, provider, metadata)); } + const registerHoverProvider = (selector: vscode.DocumentSelector, provider: vscode.HoverProvider): void => { + context.subscriptions.push(vscode.languages.registerHoverProvider(selector, provider)); + } const onDidSaveTextDocument = (listener: (e: unknown) => unknown): void => { context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(listener)); } @@ -223,6 +227,22 @@ export async function activate(context: vscode.ExtensionContext): Promise { + await vscode.env.clipboard.writeText(suggestionMessage); + vscode.window.showInformationMessage(messages.suggestions.suggestionCopiedToClipboard(engineName, ruleName)); + telemetryService.sendCommandEvent(Constants.TELEM_COPY_SUGGESTION_CLICKED, { + commandSource: Constants.COMMAND_COPY_SUGGESTION, + engineName: engineName, + ruleName: ruleName + }); + }); + const violationSuggestionsHolverProvider: ViolationSuggestionsHoverProvider = new ViolationSuggestionsHoverProvider( + diagnosticManager); + registerHoverProvider({pattern: '**/**'}, violationSuggestionsHolverProvider); + // ================================================================================================================= // == Apex Guru Integration Functionality // ================================================================================================================= diff --git a/src/lib/apexguru/apex-guru-service.ts b/src/lib/apexguru/apex-guru-service.ts index 63b50ad8..669eeedb 100644 --- a/src/lib/apexguru/apex-guru-service.ts +++ b/src/lib/apexguru/apex-guru-service.ts @@ -10,10 +10,9 @@ import * as fspromises from 'fs/promises'; import {Connection, CoreExtensionService} from '../core-extension-service'; import * as Constants from '../constants'; import {messages} from '../messages'; -import {CodeAnalyzerDiagnostic, CodeLocation, DiagnosticManager, toRange, Violation} from '../diagnostics'; +import {CodeAnalyzerDiagnostic, CodeLocation, DiagnosticManager, Violation} from '../diagnostics'; import {TelemetryService} from "../external-services/telemetry-service"; import {Logger} from "../logger"; -import { indent } from '../utils'; export async function isApexGuruEnabledInOrg(logger: Logger): Promise { try { @@ -66,8 +65,7 @@ export async function runApexGuruOnFile(uri: vscode.Uri, commandName: string, di } function getDiagnosticsWithSuggestions(diagnostics: CodeAnalyzerDiagnostic[]): CodeAnalyzerDiagnostic[] { - // If the diagnostic has relatedInformation, then it must have suggestions. - return diagnostics.filter(d => d.relatedInformation && d.relatedInformation.length > 0) + return diagnostics.filter(d => d.violation.suggestions.length > 0) } export async function pollAndGetApexGuruResponse(connection: Connection, requestId: string, maxWaitTimeInSeconds: number, retryIntervalInMillis: number): Promise { @@ -175,27 +173,11 @@ function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerD { location: violationLocation, // This message is temporary and will be improved as we get a better response back and unify the suggestions experience - message: `ApexGuru Suggestion:\n${indent(suggestedCode)}\n` + message: suggestedCode } ] } - - const diagnostic: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(violation); - - - // TODO: This is temporary until we address the unification of suggestions (which will have a better way of showing suggestions on the vscode editor window) - if (violation.suggestions?.length > 0) { - diagnostic.relatedInformation = [ - new vscode.DiagnosticRelatedInformation( - new vscode.Location( - vscode.Uri.parse(violation.suggestions[0].location.file), // When we have a better way of displaying these, we'll need a loop instead of assuming just 1 suggestion - toRange(violation.suggestions[0].location)), - violation.suggestions[0].message - ) - ]; - } - - return diagnostic; + return CodeAnalyzerDiagnostic.fromViolation(violation); } export type ApexGuruAuthResponse = { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 73ed2ae7..4157510f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,6 +19,9 @@ export const COMMAND_REMOVE_DIAGNOSTICS_ON_SELECTED_FILE = 'sfca.removeDiagnosti export const COMMAND_RUN_APEX_GURU_ON_FILE = 'sfca.runApexGuruAnalysisOnSelectedFile'; export const COMMAND_RUN_APEX_GURU_ON_ACTIVE_FILE = 'sfca.runApexGuruAnalysisOnCurrentFile'; +// other command names (which do not have to be in the package.json): +export const COMMAND_COPY_SUGGESTION = 'sfca.copySuggestion'; + // commands that are only invoked by quick fixes (which do not need to be declared in package.json since they can be registered dynamically) export const QF_COMMAND_DIAGNOSTICS_IN_RANGE = 'sfca.removeDiagnosticsInRange'; export const QF_COMMAND_A4D_FIX = 'sfca.a4dFix'; @@ -31,6 +34,7 @@ export const VSCODE_COMMAND_OPEN_URL = 'vscode.open'; export const TELEM_SUCCESSFUL_STATIC_ANALYSIS = 'sfdx__codeanalyzer_static_run_complete'; export const TELEM_FAILED_STATIC_ANALYSIS = 'sfdx__codeanalyzer_static_run_failed'; export const TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS = 'sfdx__apexguru_file_run_complete'; +export const TELEM_COPY_SUGGESTION_CLICKED = 'sfdx__codeanalyzer_copy_suggestion_clicked'; // telemetry keys used by eGPT (A4D) export const TELEM_A4D_SUGGESTION = 'sfdx__eGPT_suggest'; diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 3cc9119d..d9fdcc1e 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -143,6 +143,7 @@ export interface DiagnosticManager extends vscode.Disposable { clearDiagnostic(diag: CodeAnalyzerDiagnostic): void clearDiagnosticsInRange(uri: vscode.Uri, range: vscode.Range): void clearDiagnosticsForFiles(uris: vscode.Uri[]): void + getDiagnosticsForFile(uri: vscode.Uri): readonly CodeAnalyzerDiagnostic[] handleTextDocumentChangeEvent(event: vscode.TextDocumentChangeEvent): void } @@ -156,7 +157,7 @@ export class DiagnosticManagerImpl implements DiagnosticManager { public addDiagnostics(diags: CodeAnalyzerDiagnostic[]) { const uriToDiagsMap: Map = groupByUri(diags); for (const [uri, diags] of uriToDiagsMap) { - this.addDiagnosticsForUri(uri, diags); + this.addDiagnosticsForFile(uri, diags); } } @@ -166,9 +167,9 @@ export class DiagnosticManagerImpl implements DiagnosticManager { public clearDiagnostic(diagnostic: CodeAnalyzerDiagnostic): void { const uri: vscode.Uri = diagnostic.uri; - const currentDiagnostics: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForUri(uri); + const currentDiagnostics: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForFile(uri); const updatedDiagnostics: CodeAnalyzerDiagnostic[] = currentDiagnostics.filter(diag => diag !== diagnostic); - this.setDiagnosticsForUri(uri, updatedDiagnostics); + this.setDiagnosticsForFile(uri, updatedDiagnostics); } public dispose(): void { @@ -182,14 +183,18 @@ export class DiagnosticManagerImpl implements DiagnosticManager { } public clearDiagnosticsInRange(uri: vscode.Uri, range: vscode.Range): void { - const currentDiagnostics: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForUri(uri); + const currentDiagnostics: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForFile(uri); // Only keep the diagnostics that aren't within the specified range const updatedDiagnostics: CodeAnalyzerDiagnostic[] = currentDiagnostics.filter(diagnostic => !range.contains(diagnostic.range)); - this.setDiagnosticsForUri(uri, updatedDiagnostics); + this.setDiagnosticsForFile(uri, updatedDiagnostics); + } + + public getDiagnosticsForFile(uri: vscode.Uri): readonly CodeAnalyzerDiagnostic[] { + return (this.diagnosticCollection.get(uri) || []) as readonly CodeAnalyzerDiagnostic[]; } public handleTextDocumentChangeEvent(event: vscode.TextDocumentChangeEvent): void { - const diags: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForUri(event.document.uri); + const diags: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForFile(event.document.uri); if (diags.length === 0) { return; } @@ -202,20 +207,16 @@ export class DiagnosticManagerImpl implements DiagnosticManager { const updatedDiagnostics: CodeAnalyzerDiagnostic[] = diags .map(diag => adjustDiagnosticToChange(diag, change, replacementLines)) .filter(d => d !== null); // Removes the diagnostics that were marked for removal via null - this.setDiagnosticsForUri(event.document.uri, updatedDiagnostics); + this.setDiagnosticsForFile(event.document.uri, updatedDiagnostics); } } - private addDiagnosticsForUri(uri: vscode.Uri, newDiags: CodeAnalyzerDiagnostic[]): void { - const currentDiags: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForUri(uri); - this.setDiagnosticsForUri(uri, [...currentDiags, ...newDiags]); - } - - private getDiagnosticsForUri(uri: vscode.Uri): readonly CodeAnalyzerDiagnostic[] { - return (this.diagnosticCollection.get(uri) || []) as readonly CodeAnalyzerDiagnostic[]; + private addDiagnosticsForFile(uri: vscode.Uri, newDiags: CodeAnalyzerDiagnostic[]): void { + const currentDiags: readonly CodeAnalyzerDiagnostic[] = this.getDiagnosticsForFile(uri); + this.setDiagnosticsForFile(uri, [...currentDiags, ...newDiags]); } - private setDiagnosticsForUri(uri: vscode.Uri, diags: CodeAnalyzerDiagnostic[]): void { + private setDiagnosticsForFile(uri: vscode.Uri, diags: CodeAnalyzerDiagnostic[]): void { this.diagnosticCollection.set(uri, diags); } } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 9a3c82da..4e160deb 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -31,6 +31,10 @@ export const messages = { scanningWith: (version: string) => `Scanning with code-analyzer@${version} via CLI`, finishedScan: (scannedCount: number, badFileCount: number, violationCount: number) => `Scan complete. Analyzed ${scannedCount} files. ${violationCount} violations found in ${badFileCount} files.` }, + suggestions: { + suggestionFor: "Suggestion for", + suggestionCopiedToClipboard: (engineName: string, ruleName: string) => `Suggestion for '${engineName}.${ruleName}' copied to clipboard.` + }, fixer: { suppressPMDViolationsOnLine: "Suppress all 'pmd' violations on this line", suppressPmdViolationsOnClass: (ruleName: string) => `Suppress 'pmd.${ruleName}' on this class`, diff --git a/src/lib/violation-suggestions-hover-provider.ts b/src/lib/violation-suggestions-hover-provider.ts new file mode 100644 index 00000000..77addaa5 --- /dev/null +++ b/src/lib/violation-suggestions-hover-provider.ts @@ -0,0 +1,69 @@ +import * as vscode from "vscode" +import * as Constants from "./constants"; +import {CodeAnalyzerDiagnostic, DiagnosticManager, toRange} from "./diagnostics"; +import { messages } from "./messages"; + +/** + * Provides hover markdown for the suggestions associated with Code Analyzer Violations. + */ +export class ViolationSuggestionsHoverProvider implements vscode.HoverProvider { + private readonly diagnosticManager: DiagnosticManager; + + constructor(diagnosticManager: DiagnosticManager) { + this.diagnosticManager = diagnosticManager; + } + + provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.ProviderResult { + const allDiags: readonly CodeAnalyzerDiagnostic[] = this.diagnosticManager.getDiagnosticsForFile(document.uri); + const diagsWithSuggestions: CodeAnalyzerDiagnostic[] = allDiags.filter( + d => !d.isStale() && d.violation.suggestions?.length > 0); + + const suggestionMsgs: vscode.MarkdownString[] = []; + + // Since there is the possibility for multiple suggestions to have a location that contains the cursor position + // of the user, so we'll need to calculate the range for the hover accordingly. + let startPos: vscode.Position = null; + let endPos: vscode.Position = null; + + // For each diagnostic with a suggestion associated with this file, we find just the suggestions in this file + // whose location contains the provided position and create a single hover markdown for just those suggestions. + for (const diag of diagsWithSuggestions) { + for (const suggestion of diag.violation.suggestions) { + const suggestionRange: vscode.Range = toRange(suggestion.location); + if (suggestion.location.file === document.fileName && suggestionRange.contains(position)) { + startPos = (!startPos || suggestionRange.start.isBefore(startPos)) ? suggestionRange.start : startPos; + endPos = (!endPos || suggestionRange.end.isAfter(endPos)) ? suggestionRange.end : endPos; + suggestionMsgs.push(createMarkdownString(diag.violation.engine, diag.violation.rule, suggestion.message)); + } + } + } + + if (suggestionMsgs.length == 0) { + return; + } + + return new vscode.Hover(suggestionMsgs, new vscode.Range(startPos, endPos)); + } +} + +function createMarkdownString(engineName: string, ruleName: string, suggestionMessage): vscode.MarkdownString { + const copyTextCmdArgsAsString: string = encodeURIComponent(JSON.stringify([engineName, ruleName, suggestionMessage])) + // Using a table so that we can have a good placement and spacing for the copy button. Note we have no ability + // to use most style based tags. See the following for what tags/attributes are supported: + // https://github.com/microsoft/vscode/blob/6d2920473c6f13759c978dd89104c4270a83422d/src/vs/base/browser/markdownRenderer.ts#L296 + const markdown: vscode.MarkdownString = new vscode.MarkdownString( + `\n` + + ` \n` + + ` \n` + + ` \n` + + `\n` + + `\n` + + ` \n` + + ` \n` + + ` \n` + + `
${messages.suggestions.suggestionFor} ${engineName}.${ruleName}: $(copy) Copy
${suggestionMessage}
`); + markdown.supportHtml = true; // Using the limited html gives us a tiny bit more control that using straight-up markdown + markdown.supportThemeIcons = true; // Allows for the copy icon + markdown.isTrusted = true; // Allows the "copy" link to execute our sfca.copySuggestion command + return markdown; +} \ No newline at end of file diff --git a/src/test/legacy/apexguru/apex-guru-service.test.ts b/src/test/legacy/apexguru/apex-guru-service.test.ts index 13331e72..129dd671 100644 --- a/src/test/legacy/apexguru/apex-guru-service.test.ts +++ b/src/test/legacy/apexguru/apex-guru-service.test.ts @@ -192,7 +192,7 @@ suite('Apex Guru Test Suite', () => { startLine: 10, startColumn: 1 }, - message: `ApexGuru Suggestion:\n ${expectedSuggestedCode}\n` + message: expectedSuggestedCode }] }); }); diff --git a/src/test/legacy/test-utils.ts b/src/test/legacy/test-utils.ts index b91a6bf2..baafd9af 100644 --- a/src/test/legacy/test-utils.ts +++ b/src/test/legacy/test-utils.ts @@ -99,6 +99,10 @@ export class StubDiagnosticManager implements DiagnosticManager { // NO-OP } + getDiagnosticsForFile(_uri: vscode.Uri): readonly CodeAnalyzerDiagnostic[] { + return []; // NO-OP + } + handleTextDocumentChangeEvent(_event: vscode.TextDocumentChangeEvent): void { // NO-OP } diff --git a/src/test/unit/lib/violation-suggestions-hover-provider.test.ts b/src/test/unit/lib/violation-suggestions-hover-provider.test.ts new file mode 100644 index 00000000..e88e61dc --- /dev/null +++ b/src/test/unit/lib/violation-suggestions-hover-provider.test.ts @@ -0,0 +1,196 @@ +import * as vscode from "vscode"; // The vscode module is mocked out. See: scripts/setup.jest.ts +import { CodeAnalyzerDiagnostic, DiagnosticManager, DiagnosticManagerImpl, toRange } from "../../../lib/diagnostics"; +import { FakeDiagnosticCollection } from "../vscode-stubs"; +import { ViolationSuggestionsHoverProvider } from "../../../lib/violation-suggestions-hover-provider"; +import { createTextDocument } from "jest-mock-vscode"; +import { createSampleViolation } from "../test-utils"; + +describe('ViolationSuggestionsHoverProvider Tests', () => { + const sampleApexUri: vscode.Uri = vscode.Uri.file('/someFile.cls'); + const sampleApexContent: string = + `public class ConsolidatedClass {\n` + + ` public static void processAccountsAndContacts(List accounts) {\n` + + ` // Antipattern [Avoid using Schema.getGlobalDescribe() in Apex]: (has fix)\n` + + ` Schema.DescribeSObjectResult opportunityDescribe = Schema.getGlobalDescribe().get('Opportunity').getDescribe();\n` + + ` System.debug('Opportunity Describe: ' + opportunityDescribe);\n` + + `\n` + + ` for (Account acc : accounts) {\n` + + ` // Antipattern [SOQL in loop]:\n` + + ` List contacts = [SELECT Id, Email FROM Contact WHERE AccountId = :acc.Id];\n` + + ` for (Contact con : contacts) {\n` + + ` con.Email = 'newemail@example.com';\n` + + ` // Antipattern [DML in loop]:\n` + + ` update con;\n` + + ` }\n` + + ` }\n` + + `\n` + + ` // Antipattern [SOQL with negative expression]:\n` + + ` List contactsNotInUS = [SELECT Id, FirstName, LastName FROM Contact WHERE MailingCountry != 'US'];\n` + + ` System.debug('Contacts not in US: ' + contactsNotInUS);\n` + + `\n` + + ` // Antipattern [SOQL without WHERE clause or LIMIT]:\n` + + ` List allAccounts = [SELECT Id, Name FROM Account];\n` + + ` System.debug('All Accounts: ' + allAccounts);\n` + + `\n` + + ` // Antipattern [Using a list of SObjects for an IN-bind to ID in a SOQL]: (has suggestion)\n` + + ` List contactsFromAccounts = [SELECT Id, FirstName, LastName FROM Contact WHERE AccountId IN :accounts];\n` + + ` System.debug('Contacts from Accounts: ' + contactsFromAccounts);\n` + + `\n` + + ` // Antipattern [SOQL with wildcard filters]:\n` + + ` List accountsWithWildcard = [SELECT Id, Name FROM Account WHERE Name LIKE '%Corp%'];\n` + + ` System.debug('Accounts with wildcard: ' + accountsWithWildcard);\n` + + ` }\n` + + `}`; + const sampleApexDocument: vscode.TextDocument = createTextDocument(sampleApexUri, sampleApexContent, 'apex'); + const sampleDiag1: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 4 }, 'AvoidUsingSchemaGetGlobalDescribe', 'apexguru', [], + [{ + location: { file: sampleApexUri.fsPath, startLine: 4 }, + message: 'This is a single line suggestion that uses the same location as the violation' + }] + )); + const sampleDiag2: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 9 }, 'AvoidSOQLInLoop', 'apexguru', [], [] // no suggestions + )); + const sampleDiag3: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 13 }, 'AvoidDMLInLoop', 'apexguru', [], [] // no suggestions + )); + const sampleDiag4: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 18 }, 'AvoidSOQLWithNegativeExpression', 'apexguru', [], + [{ + location: { file: '/someOtherFile.cls', startLine: 4 }, + message: 'This is suggestion that is associated with a different file' + }] + )); + const sampleDiag5: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 22 }, 'AvoidSOQLWithoutWhereClauseOrLimit', 'apexguru', [], + [{ + location: { file: sampleApexUri.fsPath, startLine: 22, startColumn: 22, endLine: 22, endColumn: 33 }, + message: 'This is a multi\nline suggestion\nthat is only part of line 22' + }] + )); + const sampleDiag6: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 26 }, 'AvoidUsingSObjectsToInBind', 'apexguru', [], + [{ + location: { file: sampleApexUri.fsPath, startLine: 22, startColumn: 26, endLine: 23, endColumn: 6 }, + message: 'This is a suggestion associated with a violation on line 26 but it shows up between line 22 and 23' + }] + )); + const sampleDiag7: CodeAnalyzerDiagnostic = CodeAnalyzerDiagnostic.fromViolation(createSampleViolation( + { file: sampleApexUri.fsPath, startLine: 30 }, 'AvoidSOQLWithWildcardFilters', 'apexguru', [], + [{ + location: { file: sampleApexUri.fsPath, startLine: 30, endLine: 32 }, + message: 'This is a suggestion associated with a violation on line 30 but it shows up on lines 31 and 32' + }, + { + location: { file: sampleApexUri.fsPath, startLine: 30 }, + message: 'This is another suggestion that only shows up on line 30' + }] + )); + + let diagnosticCollection: vscode.DiagnosticCollection; + let diagnosticManager: DiagnosticManager; + let hoverProvider: vscode.HoverProvider; + + beforeEach(() => { + diagnosticCollection = new FakeDiagnosticCollection(); + diagnosticManager = new DiagnosticManagerImpl(diagnosticCollection); + hoverProvider = new ViolationSuggestionsHoverProvider(diagnosticManager); + diagnosticManager.addDiagnostics([sampleDiag1, sampleDiag2, sampleDiag3, sampleDiag4, sampleDiag5, sampleDiag6, sampleDiag7]); + }); + + describe('provideHover Tests', () => { + it('When the file of interest has no diagnostics, then return undefined', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover( + createTextDocument(vscode.Uri.file('./someOtherFile.cls'), 'dummy content', 'apex'), + new vscode.Position(3, 0), undefined); + expect(result).toBeUndefined(); + }); + + it('When the cursor position is not within a suggestion location, then return undefined', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover(sampleApexDocument, + new vscode.Position(8, 0), undefined); // This is line 9 which has a diagnostic but no suggestion + expect(result).toBeUndefined(); + }); + + it('When the cursor position is within a single suggestion location, then return that suggestion', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover(sampleApexDocument, new vscode.Position(3, 2), undefined); + const hover: vscode.Hover = expectHoverInstance(result); + expect(hover.contents).toHaveLength(1); + const markdown: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[0]); + expect(markdown.value).toContain("apexguru.AvoidUsingSchemaGetGlobalDescribe"); + expect(markdown.value).toContain("This is a single line suggestion that uses the same location as the violation"); + expect(markdown.value).toContain("$(copy) Copy"); + expect(markdown.isTrusted).toEqual(true); + expect(markdown.supportHtml).toEqual(true); + expect(markdown.supportThemeIcons).toEqual(true); + expect(hover.range).toEqual(toRange({startLine: 4})); + }); + + it('When the cursor position is on the line that has multiple suggestions from different diagnostics, but on the column that is only associated with one suggestion, then just return the one', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover(sampleApexDocument, + new vscode.Position(21, 22), undefined); // Line 22, Col 23 + const hover: vscode.Hover = expectHoverInstance(result); + expect(hover.contents).toHaveLength(1); + const markdown: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[0]); + expect(markdown.value).toContain("apexguru.AvoidSOQLWithoutWhereClauseOrLimit"); + expect(markdown.value).toContain("This is a multi\nline suggestion\nthat is only part of line 22"); + expect(markdown.value).toContain("$(copy) Copy"); + expect(hover.range).toEqual(toRange({ startLine: 22, startColumn: 22, endLine: 22, endColumn: 33 })); + }); + + it('When the cursor position is on the line and a column that has multiple suggestions from different diagnostics, then both are returned', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover(sampleApexDocument, + new vscode.Position(21, 25), undefined); // Line 22, Col 26 + const hover: vscode.Hover = expectHoverInstance(result); + expect(hover.contents).toHaveLength(2); + const markdown1: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[0]); + expect(markdown1.value).toContain("apexguru.AvoidSOQLWithoutWhereClauseOrLimit"); + expect(markdown1.value).toContain("This is a multi\nline suggestion\nthat is only part of line 22"); + expect(markdown1.value).toContain("$(copy) Copy"); + const markdown2: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[1]); + expect(markdown2.value).toContain("apexguru.AvoidUsingSObjectsToInBind"); + expect(markdown2.value).toContain("This is a suggestion associated with a violation on line 26 but it shows up between line 22 and 23"); + expect(markdown2.value).toContain("$(copy) Copy"); + // The range should the intersection of both code locations: + // That is 22:22 (the start of the first suggestion) to 23:6 (the end of the last suggestion) + expect(hover.range).toEqual(toRange({ startLine: 22, startColumn: 22, endLine: 23, endColumn: 6 })); + }); + + it('When the cursor position is on the line that has multiple suggestions from the same diagnostic, but on the column that is only associated with one suggestion, then just return the one', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover(sampleApexDocument, + new vscode.Position(30, 5), undefined); // Line 31, Col 6 + const hover: vscode.Hover = expectHoverInstance(result); + expect(hover.contents).toHaveLength(1); + const markdown: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[0]) + expect(markdown.value).toContain("apexguru.AvoidSOQLWithWildcardFilters"); + expect(markdown.value).toContain("This is a suggestion associated with a violation on line 30 but it shows up on lines 31 and 32"); + expect(hover.range).toEqual(toRange({ startLine: 30, endLine: 32 })); + }); + + it('When the cursor position is on the line and a column that has multiple suggestions from the same diagnostic, then both are returned', () => { + const result: vscode.ProviderResult = hoverProvider.provideHover(sampleApexDocument, + new vscode.Position(29, 0), undefined); // Line 30, Col 1 + const hover: vscode.Hover = expectHoverInstance(result); + expect(hover.contents).toHaveLength(2); + const markdown1: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[0]) + expect(markdown1.value).toContain("apexguru.AvoidSOQLWithWildcardFilters"); + expect(markdown1.value).toContain("This is a suggestion associated with a violation on line 30 but it shows up on lines 31 and 32"); + const markdown2: vscode.MarkdownString = expectMarkdownStringInstance(hover.contents[1]) + expect(markdown2.value).toContain("apexguru.AvoidSOQLWithWildcardFilters"); + expect(markdown2.value).toContain("This is another suggestion that only shows up on line 30"); + }); + }); +}); + +function expectHoverInstance(result: unknown): vscode.Hover { + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(vscode.Hover); + return result as vscode.Hover; +} + +function expectMarkdownStringInstance(result: unknown): vscode.MarkdownString { + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(vscode.MarkdownString); + return result as vscode.MarkdownString; +} \ No newline at end of file From 34e9c7ffb551918d983a7bf16cc989837f391349 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:32:13 -0400 Subject: [PATCH 11/17] CHANGE: @W-19053461@: Simplify suggestion markdown to prevent hidden copy button (#270) --- src/lib/violation-suggestions-hover-provider.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/lib/violation-suggestions-hover-provider.ts b/src/lib/violation-suggestions-hover-provider.ts index 77addaa5..84a0539f 100644 --- a/src/lib/violation-suggestions-hover-provider.ts +++ b/src/lib/violation-suggestions-hover-provider.ts @@ -48,20 +48,12 @@ export class ViolationSuggestionsHoverProvider implements vscode.HoverProvider { function createMarkdownString(engineName: string, ruleName: string, suggestionMessage): vscode.MarkdownString { const copyTextCmdArgsAsString: string = encodeURIComponent(JSON.stringify([engineName, ruleName, suggestionMessage])) - // Using a table so that we can have a good placement and spacing for the copy button. Note we have no ability - // to use most style based tags. See the following for what tags/attributes are supported: + // Note we have no ability to use most style based tags. See the following for what tags/attributes are supported: // https://github.com/microsoft/vscode/blob/6d2920473c6f13759c978dd89104c4270a83422d/src/vs/base/browser/markdownRenderer.ts#L296 const markdown: vscode.MarkdownString = new vscode.MarkdownString( - `\n` + - ` \n` + - ` \n` + - ` \n` + - `\n` + - `\n` + - ` \n` + - ` \n` + - ` \n` + - `
${messages.suggestions.suggestionFor} ${engineName}.${ruleName}: $(copy) Copy
${suggestionMessage}
`); + `${messages.suggestions.suggestionFor} ${engineName}.${ruleName}:  ` + + `$(copy) Copy\n` + + `
${suggestionMessage}
`); markdown.supportHtml = true; // Using the limited html gives us a tiny bit more control that using straight-up markdown markdown.supportThemeIcons = true; // Allows for the copy icon markdown.isTrusted = true; // Allows the "copy" link to execute our sfca.copySuggestion command From b4c0d8123a46b871429cac13db26c6d52aec7e7b Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:55:09 -0400 Subject: [PATCH 12/17] CHANGE: @W-19156063@: Improve the way we make requests to ApexGuru (#271) --- src/extension.ts | 35 +- src/lib/apexguru/apex-guru-run-action.ts | 74 ++++ src/lib/apexguru/apex-guru-service.ts | 240 ++++++------ src/lib/constants.ts | 7 +- src/lib/core-extension-service.ts | 141 ------- src/lib/diagnostics.ts | 5 + .../external-service-provider.ts | 102 ++++- .../org-connection-service.ts | 86 +++++ src/lib/fs-utils.ts | 15 + src/lib/messages.ts | 21 +- .../legacy/apexguru/apex-guru-service.test.ts | 354 ------------------ src/test/legacy/test-utils.ts | 4 + .../lib/apexguru/apex-guru-run-action.test.ts | 163 ++++++++ .../lib/apexguru/apex-guru-service.test.ts | 196 ++++++++++ src/test/unit/stubs.ts | 27 ++ 15 files changed, 809 insertions(+), 661 deletions(-) create mode 100644 src/lib/apexguru/apex-guru-run-action.ts delete mode 100644 src/lib/core-extension-service.ts create mode 100644 src/lib/external-services/org-connection-service.ts delete mode 100644 src/test/legacy/apexguru/apex-guru-service.test.ts create mode 100644 src/test/unit/lib/apexguru/apex-guru-run-action.test.ts create mode 100644 src/test/unit/lib/apexguru/apex-guru-service.test.ts diff --git a/src/extension.ts b/src/extension.ts index c3227b55..a7ae5e75 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,10 +11,8 @@ import {SettingsManager, SettingsManagerImpl} from './lib/settings'; import * as targeting from './lib/targeting' import {CodeAnalyzerDiagnostic, DiagnosticManager, DiagnosticManagerImpl} from './lib/diagnostics'; import {messages} from './lib/messages'; -import {CoreExtensionService} from './lib/core-extension-service'; import * as Constants from './lib/constants'; import * as path from 'path'; -import * as ApexGuruFunctions from './lib/apexguru/apex-guru-service'; import {ExternalServiceProvider} from "./lib/external-services/external-service-provider"; import {Logger, LoggerImpl} from "./lib/logger"; import {TelemetryService} from "./lib/external-services/telemetry-service"; @@ -34,7 +32,10 @@ import {Workspace} from "./lib/workspace"; import {PMDSupressionsCodeActionProvider} from './lib/pmd/pmd-suppressions-code-action-provider'; import {ApplyViolationFixesActionProvider} from './lib/apply-violation-fixes-action-provider'; import {ApplyViolationFixesAction} from './lib/apply-violation-fixes-action'; -import { ViolationSuggestionsHoverProvider } from './lib/violation-suggestions-hover-provider'; +import {ViolationSuggestionsHoverProvider} from './lib/violation-suggestions-hover-provider'; +import {ApexGuruService, LiveApexGuruService} from './lib/apexguru/apex-guru-service'; +import {ApexGuruRunAction} from './lib/apexguru/apex-guru-run-action'; +import {OrgConnectionService} from './lib/external-services/org-connection-service'; // Object to hold the state of our extension for a specific activation context, to be returned by our activate function @@ -83,6 +84,7 @@ export async function activate(context: vscode.ExtensionContext): Promise diagnosticManager.handleTextDocumentChangeEvent(e)); context.subscriptions.push(diagnosticManager); @@ -104,10 +106,6 @@ export async function activate(context: vscode.ExtensionContext): Promise Promise = - async () => settingsManager.getApexGuruEnabled() && + const apexGuruService: ApexGuruService = new LiveApexGuruService(orgConnectionService, fileHandler, logger); + const apexGuruRunAction: ApexGuruRunAction = new ApexGuruRunAction(taskWithProgressRunner, apexGuruService, diagnosticManager, telemetryService, display); + + // TODO: This is temporary and will change soon when we remove pilot flag and instead add a watch to org auth changes + const isApexGuruEnabled: () => Promise = async () => settingsManager.getApexGuruEnabled() && // Currently we don't watch for changes here when a user has apex guru enabled already. That is, // if the user logs into an org post activation of this extension, it won't show the command until they // refresh or toggle the "ApexGuru enabled" setting off and back on. At some point we might want to see // if it is possible to monitor changes to the users org so we can re-trigger this check. - await ApexGuruFunctions.isApexGuruEnabledInOrg(logger); - + await apexGuruService.isApexGuruAvailable(); await establishVariableInContext(Constants.CONTEXT_VAR_APEX_GURU_ENABLED, isApexGuruEnabled); // COMMAND_RUN_APEX_GURU_ON_FILE: Invokable by 'explorer/context' menu only when: "sfca.apexGuruEnabled && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/" registerCommand(Constants.COMMAND_RUN_APEX_GURU_ON_FILE, async (selection: vscode.Uri, multiSelect?: vscode.Uri[]) => - await ApexGuruFunctions.runApexGuruOnFile(multiSelect && multiSelect.length > 0 ? multiSelect[0] : selection, - Constants.COMMAND_RUN_APEX_GURU_ON_FILE, diagnosticManager, telemetryService, logger)); + await apexGuruRunAction.run(Constants.COMMAND_RUN_APEX_GURU_ON_FILE, + multiSelect && multiSelect.length > 0 ? multiSelect[0] : selection)); // TODO: We should somehow restrict multi-select here. We only use the first file right now // COMMAND_RUN_APEX_GURU_ON_ACTIVE_FILE: Invokable by 'commandPalette' and 'editor/context' menus only when: "sfca.apexGuruEnabled && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/" registerCommand(Constants.COMMAND_RUN_APEX_GURU_ON_ACTIVE_FILE, async () => { @@ -268,12 +268,9 @@ export async function activate(context: vscode.ExtensionContext): Promise { + return this.taskWithProgressRunner.runTask(async (progressReporter: ProgressReporter) => { + const startTime: number = Date.now(); + + try { + progressReporter.reportProgress({ + message: messages.apexGuru.runningAnalysis + }); + + const violations: Violation[] = await this.apexGuruService.scan(fileUri.fsPath); + + progressReporter.reportProgress({ + message: messages.scanProgressReport.processingResults, + increment: 90 + }); + + const diagnostics: CodeAnalyzerDiagnostic[] = violations.map(v => CodeAnalyzerDiagnostic.fromViolation(v)); + + const oldApexGuruDiagnostics: CodeAnalyzerDiagnostic[] = this.diagnosticManager.getDiagnosticsForFile(fileUri) + .filter(d => d.violation.engine === APEX_GURU_ENGINE_NAME); + this.diagnosticManager.clearDiagnostics(oldApexGuruDiagnostics); + this.diagnosticManager.addDiagnostics(diagnostics); + this.display.displayInfo(messages.apexGuru.finishedScan(diagnostics.length)); + + this.telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS, { + executedCommand: commandName, + duration: (Date.now() - startTime).toString(), + violationCount: violations.length.toString(), + // TODO: This telemetry might change in the near future so that we can track the number of suggestions and fixes + violationsWithSuggestedCodeCount: violations.filter(v => v.suggestions?.length > 0).length.toString() + }); + } catch (err) { + this.display.displayError(messages.error.analysisFailedGenerator(getErrorMessage(err))); + this.telemetryService.sendException(Constants.TELEM_FAILED_APEX_GURU_FILE_ANALYSIS, + getErrorMessageWithStack(err), + { + executedCommand: commandName, + duration: (Date.now() - startTime).toString() + } + ); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/apexguru/apex-guru-service.ts b/src/lib/apexguru/apex-guru-service.ts index 669eeedb..5ac4572b 100644 --- a/src/lib/apexguru/apex-guru-service.ts +++ b/src/lib/apexguru/apex-guru-service.ts @@ -5,135 +5,139 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import * as vscode from 'vscode'; -import * as fspromises from 'fs/promises'; -import {Connection, CoreExtensionService} from '../core-extension-service'; import * as Constants from '../constants'; -import {messages} from '../messages'; -import {CodeAnalyzerDiagnostic, CodeLocation, DiagnosticManager, Violation} from '../diagnostics'; -import {TelemetryService} from "../external-services/telemetry-service"; +import {CodeLocation, Violation} from '../diagnostics'; import {Logger} from "../logger"; - -export async function isApexGuruEnabledInOrg(logger: Logger): Promise { - try { - const connection = await CoreExtensionService.getConnection(); - const response:ApexGuruAuthResponse = await connection.request({ - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - return response.status == 'Success'; - } catch(e) { - // This could throw an error for a variety of reasons. The API endpoint has not been deployed to the instance, org has no perms, timeouts etc,. - // In all of these scenarios, we return false. - const errMsg = e instanceof Error ? e.message : e as string; - logger.warn('Apex Guru perm check failed with error:' + errMsg); - return false; - } +import {getErrorMessage, indent} from '../utils'; +import {HttpMethods, HttpRequest, OrgConnectionService} from '../external-services/org-connection-service'; +import {FileHandler} from '../fs-utils'; +import { messages } from '../messages'; + +export const APEX_GURU_ENGINE_NAME: string = 'apexguru'; + +const RESPONSE_STATUS = { + NEW: "new", + SUCCESS: "success", + FAILED: "failed", + ERROR: "error" } -export async function runApexGuruOnFile(uri: vscode.Uri, commandName: string, diagnosticManager: DiagnosticManager, telemetryService: TelemetryService, logger: Logger) { - const startTime = Date.now(); - try { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification - }, async (progress) => { - progress.report(messages.apexGuru.progress); - const connection = await CoreExtensionService.getConnection(); - const requestId = await initiateApexGuruRequest(uri, logger, connection); - logger.log('Code Analyzer with ApexGuru request Id:' + requestId); - - const queryResponse: ApexGuruQueryResponse = await pollAndGetApexGuruResponse(connection, requestId, Constants.APEX_GURU_MAX_TIMEOUT_SECONDS, Constants.APEX_GURU_RETRY_INTERVAL_MILLIS); - - const decodedReport = Buffer.from(queryResponse.report, 'base64').toString('utf8'); - - const diagnostics: CodeAnalyzerDiagnostic[] = transformReportJsonStringToDiagnostics(uri.fsPath, decodedReport); - diagnosticManager.addDiagnostics(diagnostics); - - telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS, { - executedCommand: commandName, - duration: (Date.now() - startTime).toString(), - violationCount: diagnostics.length.toString(), - violationsWithSuggestedCodeCount: getDiagnosticsWithSuggestions(diagnostics).length.toString() - }); - void vscode.window.showInformationMessage(messages.apexGuru.finishedScan(diagnostics.length)); - }); - } catch (e) { - const errMsg = e instanceof Error ? e.message : e as string; - logger.error('Failed to Scan for Performance Issues with ApexGuru: ' + errMsg); - } +export interface ApexGuruService { + isApexGuruAvailable(): Promise; + scan(absFileToScan: string): Promise; } -function getDiagnosticsWithSuggestions(diagnostics: CodeAnalyzerDiagnostic[]): CodeAnalyzerDiagnostic[] { - return diagnostics.filter(d => d.violation.suggestions.length > 0) -} +export class LiveApexGuruService implements ApexGuruService { + private readonly orgConnectionService: OrgConnectionService; + private readonly fileHandler: FileHandler; + private readonly logger: Logger; + private readonly maxTimeoutSeconds: number; + private readonly retryIntervalMillis: number; + constructor( + orgConnectionService: OrgConnectionService, + fileHandler: FileHandler, + logger: Logger, + maxTimeoutSeconds: number = Constants.APEX_GURU_MAX_TIMEOUT_SECONDS, + retryIntervalMillis: number = Constants.APEX_GURU_RETRY_INTERVAL_MILLIS) { + this.orgConnectionService = orgConnectionService; + this.fileHandler = fileHandler; + this.logger = logger; + this.maxTimeoutSeconds = maxTimeoutSeconds; + this.retryIntervalMillis = retryIntervalMillis; + } -export async function pollAndGetApexGuruResponse(connection: Connection, requestId: string, maxWaitTimeInSeconds: number, retryIntervalInMillis: number): Promise { - let queryResponse: ApexGuruQueryResponse; - let lastErrorMessage = ''; - const startTime = Date.now(); - while ((Date.now() - startTime) < maxWaitTimeInSeconds * 1000) { - try { - queryResponse = await connection.request({ - method: 'GET', - url: `${Constants.APEX_GURU_REQUEST}/${requestId}`, - body: '' - }); - if (queryResponse.status == 'success') { - return queryResponse; - } - } catch (error) { - lastErrorMessage = (error as Error).message; + async isApexGuruAvailable(): Promise { + if (!this.orgConnectionService.isAuthed()) { + return false; } - await new Promise(resolve => setTimeout(resolve, retryIntervalInMillis)); + const response: ApexGuruResponse = await this.request('GET', Constants.APEX_GURU_VALIDATE_ENDPOINT); + return response.status === RESPONSE_STATUS.SUCCESS; + } + async scan(absFileToScan: string): Promise { + const fileContent: string = await this.fileHandler.readFile(absFileToScan); + const requestId = await this.initiateRequest(fileContent); + this.logger.debug(`Initialized ApexGuru Analysis with Request Id: ${requestId}`); + const queryResponse: ApexGuruQueryResponse = await this.waitForResponse(requestId); + this.logger.debug(`ApexGuru Analysis completed for Request Id: ${requestId}`); + const reports: ApexGuruReport[] = toReportArray(queryResponse); + return reports.map(r => toViolation(r, absFileToScan)); } - if (queryResponse) { - return queryResponse; + + private async initiateRequest(fileContent: string): Promise { + const base64EncodedContent = Buffer.from(fileContent).toString('base64'); + const response: ApexGuruInitialResponse = await this.request('POST', Constants.APEX_GURU_REQUEST_ENDPOINT, + JSON.stringify({classContent: base64EncodedContent})); + if (!response.requestId || response.status != RESPONSE_STATUS.NEW) { + throw Error(messages.apexGuru.errors.returnedUnexpectedResponse(JSON.stringify(response, null, 2))); + } + return response.requestId; } - throw new Error(`Failed to get a successful response from Apex Guru after maximum retries.${lastErrorMessage}`); -} -export async function initiateApexGuruRequest(selection: vscode.Uri, logger: Logger, connection: Connection): Promise { - const fileContent = await fileSystem.readFile(selection.fsPath); - const base64EncodedContent = Buffer.from(fileContent).toString('base64'); - const response: ApexGuruInitialResponse = await connection.request({ - method: 'POST', - url: Constants.APEX_GURU_REQUEST, - body: JSON.stringify({ - classContent: base64EncodedContent - }) - }); - - if (response.status != 'new' && response.status != 'success') { - logger.warn('Code Analyzer with Apex Guru returned unexpected response:' + response.status); - throw Error('Code Analyzer with Apex Guru returned unexpected response:' + response.status); + private async waitForResponse(requestId: string): Promise { + const startTime = Date.now(); + let queryResponse: ApexGuruQueryResponse = undefined; + while ((Date.now() - startTime) < this.maxTimeoutSeconds * 1000) { + if (queryResponse) { // After the first attempt, we pause each time between requests + await new Promise(resolve => setTimeout(resolve, this.retryIntervalMillis)); + } + queryResponse = await this.request('GET', `${Constants.APEX_GURU_REQUEST_ENDPOINT}/${requestId}`); + if (queryResponse.status === RESPONSE_STATUS.SUCCESS && queryResponse.report) { + return queryResponse; + } else if (queryResponse.status === RESPONSE_STATUS.FAILED) { // TODO: I would love a failure message - but the response's report just gives back the file content and nothing else. + throw new Error(messages.apexGuru.errors.unableToAnalyzeFile); + } else if (queryResponse.status === RESPONSE_STATUS.ERROR && queryResponse.message) { + throw new Error(messages.apexGuru.errors.returnedUnexpectedError(queryResponse.message)); + } + } + throw new Error(messages.apexGuru.errors.failedToGetResponseBeforeTimeout(this.maxTimeoutSeconds, + JSON.stringify(queryResponse, null, 2))); } - return response.requestId; + private async request(method: HttpMethods, endpointUrl: string, body?: string): Promise { + const requestObj: HttpRequest = { + method: method, + url: endpointUrl, + body: body + }; + + try { + this.logger.trace(`Sending request to ApexGuru:\n${JSON.stringify(requestObj, null, 2)}`); + const responseObj: T = await this.orgConnectionService.request(requestObj); + this.logger.trace(`Received response from ApexGuru:\n${JSON.stringify(responseObj, null, 2)}`); + if (typeof(responseObj.status) !== "string") { + throw new Error(messages.apexGuru.errors.expectedResponseToContainStatusField( + JSON.stringify(responseObj, null, 2))); + } + // This helps things map to the RESPONSE_STATUS constants with case insensitivity + responseObj.status = responseObj.status.toLowerCase(); + + return responseObj; + } catch (err) { + this.logger.trace('Call to ApexGuru Service failed:' + getErrorMessage(err)); + return { + status: RESPONSE_STATUS.ERROR, + message: getErrorMessage(err) + } as T; + } + } } -export const fileSystem = { - readFile: (path: string) => fspromises.readFile(path, 'utf8') -}; -export function transformReportJsonStringToDiagnostics(fileName: string, jsonString: string): CodeAnalyzerDiagnostic[] { +export function toReportArray(response: ApexGuruQueryResponse): ApexGuruReport[] { + // TODO: This will change soon enough - once we receive the actual response + const report: string = Buffer.from(response.report, 'base64').toString('utf-8'); try { - const reports: ApexGuruReport[] = JSON.parse(jsonString) as ApexGuruReport[]; - return reports.map(report => reportToDiagnostic(fileName, report)); + return JSON.parse(report) as ApexGuruReport[]; } catch (err) { - const errMsg: string = err instanceof Error ? err.stack : err as string; - throw new Error(`Unable to parse response from ApexGuru: ${errMsg}`); + throw new Error(`Unable to parse response from ApexGuru.\n\n` + + `Error:\n${indent(getErrorMessage(err))}\n\nDecoded report:\n${indent(report)}`); } } -function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerDiagnostic { - // TODO: We have no need for "currentCode" right now. Temporarily leaving this code here until we get the new payload updates. - // const encodedCodeBefore = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_before')?.value - // ?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_before')?.value - // ?? ''; - // const currentCode: string = Buffer.from(encodedCodeBefore, 'base64').toString('utf8'); - +function toViolation(parsed: ApexGuruReport, file: string): Violation { + // IMPORTANT: AS OF 08/13/2024 THIS ALL FAILS IN PRODUCTION BECAUSE THE NEW ApexGuru Service NOW HAS A NEW + // RESPONSE. TODO: W-19053527 const encodedCodeAfter = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_after')?.value ?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_after')?.value ?? ''; @@ -149,7 +153,7 @@ function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerD const violation: Violation = { rule: parsed.type, - engine: 'apexguru', + engine: APEX_GURU_ENGINE_NAME, message: parsed.value, severity: 1, // TODO: Should this really be critical level violation? This seems off. locations: [violationLocation], @@ -177,31 +181,29 @@ function reportToDiagnostic(file: string, parsed: ApexGuruReport): CodeAnalyzerD } ] } - return CodeAnalyzerDiagnostic.fromViolation(violation); + return violation; } -export type ApexGuruAuthResponse = { + +type ApexGuruResponse = { status: string; + message?: string; } -export type ApexGuruInitialResponse = { - status: string; +type ApexGuruInitialResponse = ApexGuruResponse & { requestId: string; - message: string; } -export type ApexGuruQueryResponse = { - status: string; - message?: string; - report?: string; +type ApexGuruQueryResponse = ApexGuruResponse & { + report: string; } -export type ApexGuruProperty = { +type ApexGuruProperty = { name: string; value: string; }; -export type ApexGuruReport = { +type ApexGuruReport = { id: string; type: string; value: string; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4157510f..abcbb430 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,8 +6,8 @@ */ // extension names +export const EXTENSION_ID_WITHOUT_NAMESPACE = 'sfdx-code-analyzer-vscode'; export const EXTENSION_ID = 'salesforce.sfdx-code-analyzer-vscode'; -export const EXTENSION_BASE_ID = 'sfdx-code-analyzer-vscode'; export const CORE_EXTENSION_ID = 'salesforce.salesforcedx-vscode-core'; export const EXTENSION_PACK_ID = 'salesforce.salesforcedx-vscode'; @@ -34,6 +34,7 @@ export const VSCODE_COMMAND_OPEN_URL = 'vscode.open'; export const TELEM_SUCCESSFUL_STATIC_ANALYSIS = 'sfdx__codeanalyzer_static_run_complete'; export const TELEM_FAILED_STATIC_ANALYSIS = 'sfdx__codeanalyzer_static_run_failed'; export const TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS = 'sfdx__apexguru_file_run_complete'; +export const TELEM_FAILED_APEX_GURU_FILE_ANALYSIS = 'sfdx__apexguru_file_run_failed'; export const TELEM_COPY_SUGGESTION_CLICKED = 'sfdx__codeanalyzer_copy_suggestion_clicked'; // telemetry keys used by eGPT (A4D) @@ -55,8 +56,8 @@ export const RECOMMENDED_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5. export const ABSOLUTE_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0-beta.0'; // apex guru APIS -export const APEX_GURU_AUTH_ENDPOINT = '/services/data/v62.0/apexguru/validate' -export const APEX_GURU_REQUEST = '/services/data/v62.0/apexguru/request' +export const APEX_GURU_VALIDATE_ENDPOINT = '/services/data/v62.0/apexguru/validate' +export const APEX_GURU_REQUEST_ENDPOINT = '/services/data/v62.0/apexguru/request' export const APEX_GURU_MAX_TIMEOUT_SECONDS = 60; export const APEX_GURU_RETRY_INTERVAL_MILLIS = 1000; diff --git a/src/lib/core-extension-service.ts b/src/lib/core-extension-service.ts deleted file mode 100644 index 1001ef71..00000000 --- a/src/lib/core-extension-service.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import * as vscode from 'vscode'; -import * as semver from 'semver'; -import {SettingsManagerImpl} from './settings'; - -import { CORE_EXTENSION_ID, MINIMUM_REQUIRED_VERSION_CORE_EXTENSION } from './constants'; -/** - * Manages access to the services exported by the Salesforce VSCode Extension Pack's core extension. - * If the extension pack isn't installed, only performs no-ops. - */ -export class CoreExtensionService { - private static initialized = false; - private static workspaceContext: WorkspaceContext; - - public static async loadDependencies(outputChannel: vscode.LogOutputChannel): Promise { - if (!CoreExtensionService.initialized) { - const coreExtensionApi = await this.getCoreExtensionApiOrUndefined(); - - // TODO: For testability, this should probably be passed in, instead of instantiated. - if (new SettingsManagerImpl().getApexGuruEnabled()) { - CoreExtensionService.initializeWorkspaceContext(coreExtensionApi?.services.WorkspaceContext, outputChannel); - } - CoreExtensionService.initialized = true; - } - } - - private static async getCoreExtensionApiOrUndefined(): Promise { - // Note that when we get an extension, then it's "exports" field is the provided the return value - // of the extensions activate method. If the activate method hasn't been called, then this won't be filled in. - // Also note that the type of the return of the activate method is the templated type T of the Extension. - - const coreExtension: vscode.Extension = vscode.extensions.getExtension(CORE_EXTENSION_ID); - if (!coreExtension) { - console.log(`${CORE_EXTENSION_ID} not found; cannot load core dependencies. Returning undefined instead.`); - return undefined; - } - - const pkgJson: {version: string} = coreExtension.packageJSON as {version: string}; - - // We know that there has to be a `version` property on the package.json object. - const coreExtensionVersion = pkgJson.version; - if (semver.lt(coreExtensionVersion, MINIMUM_REQUIRED_VERSION_CORE_EXTENSION)) { - console.log(`${CORE_EXTENSION_ID} below minimum viable version; cannot load core dependencies. Returning undefined instead.`); - return undefined; - } - - if (!coreExtension.isActive) { - console.log(`${CORE_EXTENSION_ID} present but inactive. Activating now.`); - await coreExtension.activate(); // will call the extensions activate function and fill in the exports property with its return value - } - - console.log(`${CORE_EXTENSION_ID} present and active. Returning its exported API.`); - return coreExtension.exports; - } - - private static initializeWorkspaceContext(workspaceContext: WorkspaceContext | undefined, outputChannel: vscode.LogOutputChannel) { - if (!workspaceContext) { - outputChannel.warn('***Workspace Context not present in core dependency API. Check if the Core Extension installed.***'); - outputChannel.show(); - } - CoreExtensionService.workspaceContext = workspaceContext.getInstance(false); - } - - static async getWorkspaceOrgId(): Promise { - if (CoreExtensionService.initialized) { - const connection = await CoreExtensionService.workspaceContext.getConnection(); - return connection.getAuthInfoFields().orgId ?? ''; - } - throw new Error('***Org not initialized***'); - } - - static async getConnection(): Promise { - const connection = await CoreExtensionService.workspaceContext.getConnection(); - return connection; - } -} - -// See https://github.com/forcedotcom/salesforcedx-vscode/blob/develop/packages/salesforcedx-vscode-core/src/index.ts#L479 -interface CoreExtensionApi { - services: { - WorkspaceContext: WorkspaceContext; - } -} - - - - -// TODO: Move all this Workspace Context stuff over into the external-services-provider so that we can instead pass in -// the connection into the apex-guru-service code using dependency injection instead of all the global stuff. - - -// See https://github.com/forcedotcom/salesforcedx-vscode/blob/develop/packages/salesforcedx-vscode-core/src/context/workspaceContext.ts -interface WorkspaceContext { - // Note that the salesforce.salesforcedx-vscode-core extension's active method doesn't actually return an instance - // of this service, but instead returns the class. We must use the getInstance static method to create the instance. - getInstance(forceNew: boolean): WorkspaceContext; - - // We need the connection, but no other instance methods currently - getConnection(): Promise; -} - -export type AuthFields = { - accessToken?: string; - alias?: string; - authCode?: string; - clientId?: string; - clientSecret?: string; - created?: string; - createdOrgInstance?: string; - devHubUsername?: string; - instanceUrl?: string; - instanceApiVersion?: string; - instanceApiVersionLastRetrieved?: string; - isDevHub?: boolean; - loginUrl?: string; - orgId?: string; - password?: string; - privateKey?: string; - refreshToken?: string; - scratchAdminUsername?: string; - snapshot?: string; - userId?: string; - username?: string; - usernames?: string[]; - userProfileName?: string; - expirationDate?: string; - tracksSource?: boolean; -}; - - -// See https://github.com/forcedotcom/sfdx-core/blob/main/src/org/connection.ts#L69 -export interface Connection { - getApiVersion(): string; - getAuthInfoFields(): AuthFields; - request(options: { method: string; url: string; body: string; headers?: Record }): Promise; -} diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index d9fdcc1e..30750b56 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -141,6 +141,7 @@ export interface DiagnosticManager extends vscode.Disposable { addDiagnostics(diags: CodeAnalyzerDiagnostic[]): void clearAllDiagnostics(): void clearDiagnostic(diag: CodeAnalyzerDiagnostic): void + clearDiagnostics(diags: CodeAnalyzerDiagnostic[]): void clearDiagnosticsInRange(uri: vscode.Uri, range: vscode.Range): void clearDiagnosticsForFiles(uris: vscode.Uri[]): void getDiagnosticsForFile(uri: vscode.Uri): readonly CodeAnalyzerDiagnostic[] @@ -172,6 +173,10 @@ export class DiagnosticManagerImpl implements DiagnosticManager { this.setDiagnosticsForFile(uri, updatedDiagnostics); } + public clearDiagnostics(diags: CodeAnalyzerDiagnostic[]): void { + diags.map(d => this.clearDiagnostic(d)); + } + public dispose(): void { this.clearAllDiagnostics(); } diff --git a/src/lib/external-services/external-service-provider.ts b/src/lib/external-services/external-service-provider.ts index 5444ae7e..ea955bdd 100644 --- a/src/lib/external-services/external-service-provider.ts +++ b/src/lib/external-services/external-service-provider.ts @@ -1,3 +1,6 @@ +import * as vscode from "vscode"; +import * as semver from 'semver'; +import * as Constants from "../constants"; import { LLMServiceInterface, ServiceProvider, @@ -12,23 +15,31 @@ import { } from "./telemetry-service"; import {Logger} from "../logger"; import {LiveLLMService, LLMService, LLMServiceProvider} from "./llm-service"; -import {Extension} from "vscode"; -import * as vscode from "vscode"; -import * as Constants from "../constants"; +import { + LiveOrgConnectionService, + NoOpOrgConnectionService, + OrgConnectionService, + OrgConnectionServiceProvider, + WorkspaceContext +} from "./org-connection-service"; +import { getErrorMessageWithStack } from "../utils"; + const EXTENSION_THAT_SUPPLIES_LLM_SERVICE = 'salesforce.salesforcedx-einstein-gpt'; -const EXTENSION_THAT_SUPPLIES_TELEMETRY_SERVICE = 'salesforce.salesforcedx-vscode-core'; +const EXTENSION_THAT_SUPPLIES_TELEMETRY_SERVICE = Constants.CORE_EXTENSION_ID; +const EXTENSION_THAT_SUPPLIES_WORKSPACE_CONTEXT = Constants.CORE_EXTENSION_ID; /** * Provides and caches a number of external services that we use like the LLM service, telemetry service, etc. */ -export class ExternalServiceProvider implements LLMServiceProvider, TelemetryServiceProvider { +export class ExternalServiceProvider implements LLMServiceProvider, TelemetryServiceProvider, OrgConnectionServiceProvider { private readonly logger: Logger; private readonly extensionContext: vscode.ExtensionContext; private cachedLLMService?: LLMService; private cachedTelemetryService?: TelemetryService; + private cachedOrgConnectionService?: OrgConnectionService; constructor(logger: Logger, extensionContext: vscode.ExtensionContext) { this.logger = logger; @@ -59,8 +70,7 @@ export class ExternalServiceProvider implements LLMServiceProvider, TelemetrySer const coreLLMService: LLMServiceInterface = await ServiceProvider.getService(ServiceType.LLMService, Constants.EXTENSION_ID); return new LiveLLMService(coreLLMService, this.logger); } catch (err) { - const errMsg: string = err instanceof Error? err.stack : String(err); - this.logger.error(`Could not establish LLM service due to unexpected error:\n${errMsg}`); + this.logger.error(`Could not establish LLM service due to unexpected error:\n${getErrorMessageWithStack(err)}`); throw err; } } @@ -83,23 +93,65 @@ export class ExternalServiceProvider implements LLMServiceProvider, TelemetrySer private async initializeTelemetryService(): Promise { if (!(await this.isTelemetryServiceAvailable())) { - this.logger.debug('Could not establish live telemetry service since it is not available. ' + + this.logger.debug('Could not establish the live telemetry service since it is not available. ' + 'Most likely you do not have the "Salesforce CLI Integration" Core Extension installed in VS Code.'); return new LogOnlyTelemetryService(this.logger); } try { - const coreTelemetryService: TelemetryServiceInterface = await ServiceProvider.getService(ServiceType.Telemetry, Constants.EXTENSION_BASE_ID); + const coreTelemetryService: TelemetryServiceInterface = await ServiceProvider.getService(ServiceType.Telemetry, + Constants.EXTENSION_ID_WITHOUT_NAMESPACE); // The telemetry service seems to require the id without the namespace prefix await coreTelemetryService.initializeService(this.extensionContext); return new LiveTelemetryService(coreTelemetryService, this.logger); } catch (err) { - const errMsg: string = err instanceof Error? err.stack : String(err); - this.logger.error(`Could not establish live telemetry service due to unexpected error:\n${errMsg}`); + this.logger.error(`Could not establish live telemetry service due to unexpected error:\n${getErrorMessageWithStack(err)}`); return new LogOnlyTelemetryService(this.logger); } } + // ================================================================================================================= + // === OrgConnectionServiceProvider implementation + // ================================================================================================================= + async isOrgConnectionServiceAvailable(): Promise { + const coreExtension: vscode.Extension = await this.waitForExtensionToBeActivatedIfItExists(EXTENSION_THAT_SUPPLIES_WORKSPACE_CONTEXT); + if (!coreExtension) { + return false; + } + const pkgJson: {version: string} = coreExtension.packageJSON as {version: string}; + if (semver.lt(pkgJson.version, Constants.MINIMUM_REQUIRED_VERSION_CORE_EXTENSION)) { + this.logger.warn(`The version of the extension '${EXTENSION_THAT_SUPPLIES_WORKSPACE_CONTEXT}' is below the minimum version required for Salesforce Code Analyzer to provide full functionality. Please upgrade to version ${Constants.MINIMUM_REQUIRED_VERSION_CORE_EXTENSION} or greater.`); + return false; + } + return true; + } + + async getOrgConnectionService(): Promise { + if (!this.cachedOrgConnectionService) { + this.cachedOrgConnectionService = await this.initializeOrgConnectionService(); + } + return this.cachedOrgConnectionService; + } + + private async initializeOrgConnectionService(): Promise { + if (!(await this.isOrgConnectionServiceAvailable())) { + this.logger.debug('Could not establish the live org connection service since it is not available. ' + + 'Most likely you do not have the "Salesforce CLI Integration" Core Extension installed in VS Code.'); + return new NoOpOrgConnectionService(); + } + try { + // Ideally we would get the WorkspaceContext from the ServiceProvider but it is not on the ServiceProvider. + // So instead, we get it off of the core extension's returned api. + const coreExtension: vscode.Extension = vscode.extensions.getExtension(EXTENSION_THAT_SUPPLIES_WORKSPACE_CONTEXT); + const workspaceContext: WorkspaceContext = coreExtension.exports.services.WorkspaceContext.getInstance(); + return new LiveOrgConnectionService(workspaceContext); + + } catch (err) { + this.logger.error(`Could not establish Org Connection service due to unexpected error:\n${getErrorMessageWithStack(err)}`); + throw err; + } + } + // ================================================================================================================= // TODO: The following is a temporary workaround to the problem that our extension might activate before @@ -107,14 +159,14 @@ export class ExternalServiceProvider implements LLMServiceProvider, TelemetrySer // it is available and after 2 seconds just force activate it. The service provider should do this automatically for // us. Until then, we'll keep this workaround in place (which is not preferred because it requires us to hard code // the extension name that each service comes from which theoretically could be subject to change over time). - // Returns true if the extension activated and false if the extension doesn't exist or could not be activated. - private async waitForExtensionToBeActivatedIfItExists(extensionName: string): Promise { - const extension: Extension = vscode.extensions.getExtension(extensionName); + // Returns the extension if the extension activated and undefined if the extension doesn't exist or could not be activated. + private async waitForExtensionToBeActivatedIfItExists(extensionName: string): Promise | undefined> { + const extension: vscode.Extension = vscode.extensions.getExtension(extensionName); if (!extension) { - this.logger.debug(`The extension '${extensionName}' was not found. Some functionality that depends on this extension will not be available.`); - return false; + this.logger.warn(`The extension '${extensionName}' was not found. Some functionality that depends on this extension will not be available.`); + return undefined; } else if (extension.isActive) { - return true; + return extension; } this.logger.debug(`The extension '${extensionName}' was found but has not yet activated. Waiting up to 5 seconds for it to activate.`); @@ -133,7 +185,7 @@ export class ExternalServiceProvider implements LLMServiceProvider, TelemetrySer if (eventuallyBecameActive) { this.logger.debug(`The extension '${extensionName}' has activated successfully.`); - return true; + return extension; } // Ideally we shouldn't be force activating it, but it's the best thing we can do after waiting 2 seconds as a @@ -142,11 +194,21 @@ export class ExternalServiceProvider implements LLMServiceProvider, TelemetrySer try { await extension.activate(); this.logger.debug(`The extension '${extensionName}' has activated successfully.`); - return true; + return extension; } catch (err) { const errMsg: string = err instanceof Error ? err.stack : String(err); this.logger.debug(`The extension '${extensionName}' could not activate due to an unexpected exception:\n${errMsg}`); - return false; + return undefined; } } } + +// The only thing we care about from the returned Core Extension API is the WorkspaceContext service for now. +// See https://github.com/forcedotcom/salesforcedx-vscode/blob/develop/packages/salesforcedx-vscode-core/src/index.ts#L479 +interface CoreExtensionApi { + services: { + WorkspaceContext: { + getInstance(): WorkspaceContext; + }; + } +} \ No newline at end of file diff --git a/src/lib/external-services/org-connection-service.ts b/src/lib/external-services/org-connection-service.ts new file mode 100644 index 00000000..2aac0eda --- /dev/null +++ b/src/lib/external-services/org-connection-service.ts @@ -0,0 +1,86 @@ +import * as vscode from "vscode"; + +export interface OrgConnectionService { + isAuthed(): boolean; + onOrgChange(callback: () => void): void; + request(requestOptions: HttpRequest): Promise; +} + +export interface OrgConnectionServiceProvider { + isOrgConnectionServiceAvailable(): Promise + getOrgConnectionService(): Promise +} + +export class NoOpOrgConnectionService implements OrgConnectionService { + isAuthed(): boolean { + return false; + } + onOrgChange(_callback: () => void): void { + // No-op + } + request(requestOptions: HttpRequest): Promise { + throw new Error(`Cannot make the following request because no org is authed:\n${JSON.stringify(requestOptions, null, 2)}`); + } +} + +export class LiveOrgConnectionService implements OrgConnectionService { + private readonly workpaceContext: WorkspaceContext; + + constructor(workspaceContext: WorkspaceContext) { + this.workpaceContext = workspaceContext; + } + + isAuthed(): boolean { + return this.workpaceContext.orgId?.length > 0; + } + + onOrgChange(callback: (event: OrgUserInfo) => void): void { + this.workpaceContext.onOrgChange(callback); + } + + async request(requestOptions: HttpRequest): Promise { + if (!this.isAuthed()) { + throw new Error(`Cannot make the following request because no org is authed:\n${JSON.stringify(requestOptions, null, 2)}`); + } + const connection: Connection = await this.workpaceContext.getConnection(); + return await connection.request(requestOptions); + } +} + +// See https://github.com/forcedotcom/salesforcedx-vscode/blob/develop/packages/salesforcedx-utils-vscode/src/context/workspaceContextUtil.ts#L15 +export type OrgUserInfo = { + username?: string; + alias?: string; +} + +// See https://github.com/jsforce/jsforce/blob/main/src/types/common.ts#L32 +export type HttpRequest = { + url: string; + method: HttpMethods; + body?: string; + headers?: Record +} +export type HttpMethods = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'OPTIONS' + | 'HEAD'; + +// See https://github.com/forcedotcom/salesforcedx-vscode/blob/develop/packages/salesforcedx-vscode-core/src/context/workspaceContext.ts#L23 +export interface WorkspaceContext { + readonly onOrgChange: vscode.Event; + getConnection(): Promise; + get username(): string | undefined; + get alias(): string | undefined; + get orgId(): string | undefined; +} + + +// See https://github.com/forcedotcom/sfdx-core/blob/main/src/org/connection.ts#L71 +export interface Connection { + getApiVersion(): string; + request(requestOptions: HttpRequest): Promise; +} diff --git a/src/lib/fs-utils.ts b/src/lib/fs-utils.ts index f3028418..711de524 100644 --- a/src/lib/fs-utils.ts +++ b/src/lib/fs-utils.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as tmp from 'tmp'; import {promisify} from "node:util"; +import {getErrorMessageWithStack} from './utils'; tmp.setGracefulCleanup(); const tmpFileAsync = promisify((options: tmp.FileOptions, cb: tmp.FileCallback) => tmp.file(options, cb)); @@ -29,6 +30,12 @@ export interface FileHandler { * @param ext - optional extension to apply to the file */ createTempFile(ext?: string): Promise + + /** + * Reads and returns a file's contents + * @param file - file path + */ + readFile(file: string): Promise } export class FileHandlerImpl implements FileHandler { @@ -48,4 +55,12 @@ export class FileHandlerImpl implements FileHandler { async createTempFile(ext?: string): Promise { return await tmpFileAsync(ext ? {postfix: ext}: {}); } + + async readFile(file: string): Promise { + try { + return fs.promises.readFile(file, 'utf-8'); + } catch (err) { + throw new Error(`Could not read file '${file}'.\n${getErrorMessageWithStack(err)}`); + } + } } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 4e160deb..67176ea0 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -11,7 +11,7 @@ export const messages = { verifyingCodeAnalyzerIsInstalled: "Verifying Code Analyzer CLI plugin is installed.", identifyingTargets: "Code Analyzer is identifying targets.", analyzingTargets: "Code Analyzer is analyzing targets.", - processingResults: "Code Analyzer is processing results." + processingResults: "Code Analyzer is processing results." // Shared with ApexGuru and CodeAnalyzer }, agentforce: { a4dQuickFixUnavailable: "The ability to fix violations with 'Agentforce for Developers' is unavailable since a compatible 'Agentforce for Developers' extension was not found or activated. To enable this functionality, please install the 'Agentforce for Developers' extension and restart VS Code.", @@ -22,10 +22,19 @@ export const messages = { editorCodeLensMustBeEnabled: "This action requires the 'Editor: Code Lens' setting to be enabled." }, apexGuru: { - progress: { - message: "Code Analyzer is running ApexGuru analysis." - }, - finishedScan: (violationCount: number) => `Scan complete. ${violationCount} violations found.` + runningAnalysis: "Code Analyzer is running ApexGuru analysis.", + finishedScan: (violationCount: number) => `Scan complete. ${violationCount} violations found.`, + errors: { + unableToAnalyzeFile: 'ApexGuru was unable to analyze the file.', + returnedUnexpectedResponse: (responseStr: string) => + `ApexGuru returned an unexpected response:\n${responseStr}`, + returnedUnexpectedError: (errMsg: string) => `ApexGuru returned an unexpected error: ${errMsg}`, + failedToGetResponseBeforeTimeout: (maxSeconds: number, lastResponse: string) => + `Failed to get a successful response from ApexGuru after ${maxSeconds} seconds.\n` + + `Last response:\n${lastResponse}`, + expectedResponseToContainStatusField: (responseStr: string) => + `ApexGuru returned a response without a 'status' field containing a string value. Instead received:\n${responseStr}` + } }, info: { scanningWith: (version: string) => `Scanning with code-analyzer@${version} via CLI`, @@ -61,7 +70,7 @@ export const messages = { installLatestVersion: 'Install the latest `code-analyzer` Salesforce CLI plugin by running `sf plugins install code-analyzer` in the VS Code integrated terminal.', }, error: { - analysisFailedGenerator: (reason: string) => `Analysis failed: ${reason}`, + analysisFailedGenerator: (reason: string) => `Analysis failed: ${reason}`, // Shared with ApexGuru and CodeAnalyzer engineUninstantiable: (engine: string) => `Error: Couldn't initialize engine "${engine}" due to a setup error. Analysis continued without this engine. Click "Show error" to see the error message. Click "Ignore error" to ignore the error for this session. Click "Learn more" to view the system requirements for this engine, and general instructions on how to set up Code Analyzer.`, pmdConfigNotFoundGenerator: (file: string) => `PMD custom config file couldn't be located. [${file}]. Check Salesforce Code Analyzer > PMD > Custom Config settings`, sfMissing: "To use the Salesforce Code Analyzer extension, first install Salesforce CLI.", diff --git a/src/test/legacy/apexguru/apex-guru-service.test.ts b/src/test/legacy/apexguru/apex-guru-service.test.ts deleted file mode 100644 index 129dd671..00000000 --- a/src/test/legacy/apexguru/apex-guru-service.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Copyright (c) 2024, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {expect} from 'chai'; -import * as Sinon from 'sinon'; -import {CodeAnalyzerDiagnostic} from '../../../lib/diagnostics'; -import {CoreExtensionService} from '../../../lib/core-extension-service'; -import * as Constants from '../../../lib/constants'; -import * as ApexGuruFunctions from '../../../lib/apexguru/apex-guru-service' -import { Connection } from '../../../lib/core-extension-service'; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from 'vscode'; -import {SpyLogger} from "../test-utils"; - -suite('Apex Guru Test Suite', () => { - suite('#_isApexGuruEnabledInOrg', () => { - let getConnectionStub: Sinon.SinonStub; - let requestStub: Sinon.SinonStub; - - setup(() => { - getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection'); - requestStub = Sinon.stub(); - }); - - teardown(() => { - Sinon.restore(); - }); - - test('Returns true if response status is Success', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.resolves({ status: 'Success' }) - }); - - // ===== TEST ===== - const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(new SpyLogger()); - - // ===== ASSERTIONS ===== - expect(result).to.equal(true); - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledWith(requestStub, { - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - }); - - test('Returns false if response status is not Success', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.resolves({ status: 'Failure' }) - }); - - // ===== TEST ===== - const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(new SpyLogger()); - - // ===== ASSERTIONS ===== - expect(result).to.equal(false); - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledWith(requestStub, { - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - }); - - test('Returns false if an error is thrown', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.rejects(new Error('Resource not found')) - }); - - // ===== TEST ===== - const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(new SpyLogger()); - - // ===== ASSERTIONS ===== - expect(result).to.equal(false); - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledWith(requestStub, { - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - }); - }); - - suite('#initiateApexGuruRequest', () => { - let getConnectionStub: Sinon.SinonStub; - let requestStub: Sinon.SinonStub; - let readFileStub: Sinon.SinonStub; - - setup(() => { - getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection'); - requestStub = Sinon.stub(); - readFileStub = Sinon.stub(ApexGuruFunctions.fileSystem, 'readFile'); - }); - - teardown(() => { - Sinon.restore(); - }); - - test('Returns requestId if response status is new', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.resolves({ status: 'new', requestId: '12345' }) - }); - readFileStub.resolves('console.log("Hello World");'); - const connection = await CoreExtensionService.getConnection(); - - // ===== TEST ===== - const result = await ApexGuruFunctions.initiateApexGuruRequest(vscode.Uri.file('dummyPath'), new SpyLogger(), connection); - - // ===== ASSERTIONS ===== - expect(result).to.equal('12345'); - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledOnce(readFileStub); - Sinon.assert.calledWith(requestStub, Sinon.match({ - method: 'POST', - url: Constants.APEX_GURU_REQUEST, - body: Sinon.match.string - })); - }); - - test('Logs warning if response status is not new', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.resolves({ status: 'failed' }) - }); - readFileStub.resolves('console.log("Hello World");'); - const connection = await CoreExtensionService.getConnection(); - const spyLogger: SpyLogger = new SpyLogger(); - // ===== TEST ===== - try { - await ApexGuruFunctions.initiateApexGuruRequest(vscode.Uri.file('dummyPath'), spyLogger, connection); - } catch (_e) { - // ===== ASSERTIONS ===== - expect(spyLogger.warnCallHistory.length).to.be.greaterThan(0); - } - }); - }); - - suite('#transformReportJsonStringToDiagnostics', () => { - test('Transforms valid JSON string to Diagnostics for soql violations', () => { - const fileName = 'TestFile.cls'; - const jsonString = JSON.stringify([{ - type: 'BestPractices', - value: 'Avoid using System.debug', - properties: [ - { name: 'line_number', value: '10' }, - { name: 'code_after', value: Buffer.from('System.out.println("New Hello World");').toString('base64') }, - { name: 'code_before', value: Buffer.from('System.out.println("Old Hello World");').toString('base64') } - ] - }]); - - const diagnostics: CodeAnalyzerDiagnostic[] = ApexGuruFunctions.transformReportJsonStringToDiagnostics(fileName, jsonString); - expect(diagnostics).to.have.length(1); - const expectedSuggestedCode: string = 'System.out.println("New Hello World");'; - expect(diagnostics[0].violation).to.deep.equal({ - rule: 'BestPractices', - engine: 'apexguru', - message: 'Avoid using System.debug', - severity: 1, - locations: [{ - file: fileName, - startLine: 10, - startColumn: 1 - }], - primaryLocationIndex: 0, - tags: [], - resources: ['https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5'], - fixes: [{ - location: { - file: fileName, - startLine: 10, - startColumn: 1 - }, - fixedCode: `/*\n//ApexGuru Suggestions: \n${expectedSuggestedCode}\n*/` - }], - suggestions: [{ - location: { - file: fileName, - startLine: 10, - startColumn: 1 - }, - message: expectedSuggestedCode - }] - }); - }); - - test('Transforms valid JSON string to Violations for violations with no suggestions', () => { - const fileName = 'TestFile.cls'; - const jsonString = JSON.stringify([{ - type: 'BestPractices', - value: 'Avoid using System.debug', - properties: [ - { name: 'line_number', value: '10' } ] - }]); - - const diagnostics: CodeAnalyzerDiagnostic[] = ApexGuruFunctions.transformReportJsonStringToDiagnostics(fileName, jsonString); - expect(diagnostics).to.have.length(1); - expect(diagnostics[0].violation).to.deep.equal({ - rule: 'BestPractices', - engine: 'apexguru', - message: 'Avoid using System.debug', - severity: 1, - tags: [], - locations: [{ - file: fileName, - startLine: 10, - startColumn: 1 - }], - primaryLocationIndex: 0, - resources: ['https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5'] - }); - expect(diagnostics[0].relatedInformation).to.equal(undefined); - }); - - test('Handles empty JSON string', () => { - const fileName = 'TestFile.cls'; - const jsonString = ''; - - expect(() => ApexGuruFunctions.transformReportJsonStringToDiagnostics(fileName, jsonString)).to.throw(); - }); - }); - - suite('#pollAndGetApexGuruResponse', () => { - let connectionStub: Sinon.SinonStubbedInstance; - - setup(() => { - connectionStub = { - getApiVersion: Sinon.stub(), - getAuthInfoFields: Sinon.stub(), - request: Sinon.stub() - } as Sinon.SinonStubbedInstance; - }); - - teardown(() => { - Sinon.restore(); - }); - - test('Returns response on successful query within timeout', async () => { - // ===== SETUP ===== - const requestId = 'dummyRequestId'; - const maxWaitTimeInSeconds = 5; - const retryInterval = 100; - - const queryResponse: ApexGuruFunctions.ApexGuruQueryResponse = { status: 'success', report: '' }; - connectionStub.request.resolves(queryResponse); - - // ===== TEST ===== - const response = await ApexGuruFunctions.pollAndGetApexGuruResponse(connectionStub as unknown as Connection, requestId, maxWaitTimeInSeconds, retryInterval); - - // ===== ASSERTIONS ===== - expect(response).to.deep.equal(queryResponse); - expect(connectionStub.request.calledOnce).to.equal(true); - }); - - test('Retries until successful response within timeout', async () => { - // ===== SETUP ===== - const requestId = 'dummyRequestId'; - const maxWaitTimeInSeconds = 5; - const retryInterval = 100; - - // ===== TEST ===== - const pendingResponse: ApexGuruFunctions.ApexGuruQueryResponse = { status: 'pending', report: '' }; - const successResponse: ApexGuruFunctions.ApexGuruQueryResponse = { status: 'success', report: '' }; - - connectionStub.request.onCall(0).resolves(pendingResponse); - connectionStub.request.onCall(1).resolves(pendingResponse); - connectionStub.request.onCall(2).resolves(successResponse); - - const response = await ApexGuruFunctions.pollAndGetApexGuruResponse(connectionStub as unknown as Connection, requestId, maxWaitTimeInSeconds, retryInterval); - - // ===== ASSERTIONS ===== - expect(response).to.deep.equal(successResponse); - expect(connectionStub.request.callCount).to.equal(3); - }); - - test('Throws error after timeout is exceeded', async () => { - // ===== SETUP ===== - const requestId = 'dummyRequestId'; - const maxWaitTimeInSeconds = 0; - const retryInterval = 100; - - const pendingResponse: ApexGuruFunctions.ApexGuruQueryResponse = { status: 'new', report: '' }; - connectionStub.request.resolves(pendingResponse); - - // ===== TEST ===== - try { - await ApexGuruFunctions.pollAndGetApexGuruResponse(connectionStub as unknown as Connection, requestId, maxWaitTimeInSeconds, retryInterval); - throw new Error('Expected to throw an error due to timeout'); - } catch (error) { - expect((error as Error).message).to.equal('Failed to get a successful response from Apex Guru after maximum retries.'); - } - - // ===== ASSERTIONS ===== - const expectedCallCount = Math.floor((maxWaitTimeInSeconds * 1000) / retryInterval); - expect(connectionStub.request.callCount).to.be.at.least(expectedCallCount); - }); - - test('Handles request errors and continues retrying', async () => { - // ===== SETUP ===== - const requestId = 'dummyRequestId'; - const maxWaitTimeInSeconds = 5; - const retryInterval = 100; - - const pendingResponse: ApexGuruFunctions.ApexGuruQueryResponse = { status: 'new', report: '' }; - const successResponse: ApexGuruFunctions.ApexGuruQueryResponse = { status: 'success', report: '' }; - - connectionStub.request.onCall(0).rejects(new Error('Some error')); - connectionStub.request.onCall(1).resolves(pendingResponse); - connectionStub.request.onCall(2).resolves(successResponse); - - // ===== TEST ===== - const response = await ApexGuruFunctions.pollAndGetApexGuruResponse(connectionStub as unknown as Connection, requestId, maxWaitTimeInSeconds, retryInterval); - - // ===== ASSERTIONS ===== - expect(response).to.deep.equal(successResponse); - expect(connectionStub.request.callCount).to.equal(3); - }); - - test('Throws last error if maximum retries are exhausted', async () => { - // ===== SETUP ===== - const requestId = 'dummyRequestId'; - const maxWaitTimeInSeconds = 1; // Set to 1 second for quick test - const retryInterval = 500; - - const errorResponse: Error = new Error('Some dummy error'); - - connectionStub.request.rejects(errorResponse); - - // ===== TEST ===== - try { - await ApexGuruFunctions.pollAndGetApexGuruResponse(connectionStub as unknown as Connection, requestId, maxWaitTimeInSeconds, retryInterval); - expect.fail('Expected function to throw an error'); - } catch (error) { - // ===== ASSERTIONS ===== - expect((error as Error).message).to.contain('Failed to get a successful response from Apex Guru after maximum retries.Some dummy error'); - } - - expect(connectionStub.request.callCount).to.be.greaterThan(0); - }); - }); -}); diff --git a/src/test/legacy/test-utils.ts b/src/test/legacy/test-utils.ts index baafd9af..ead8e276 100644 --- a/src/test/legacy/test-utils.ts +++ b/src/test/legacy/test-utils.ts @@ -91,6 +91,10 @@ export class StubDiagnosticManager implements DiagnosticManager { // NO-OP } + clearDiagnostics(_diags: CodeAnalyzerDiagnostic[]): void { + // NO-OP + } + clearDiagnosticsInRange(_uri: vscode.Uri, _range: vscode.Range): void { // NO-OP } diff --git a/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts b/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts new file mode 100644 index 00000000..514181b0 --- /dev/null +++ b/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts @@ -0,0 +1,163 @@ +import * as vscode from "vscode";// The vscode module is mocked out. See: scripts/setup.jest.ts + +import * as stubs from "../../stubs"; +import { CodeAnalyzerDiagnostic, DiagnosticManager, DiagnosticManagerImpl, Violation } from "../../../../lib/diagnostics"; +import { FakeDiagnosticCollection } from "../../vscode-stubs"; +import { ApexGuruRunAction } from "../../../../lib/apexguru/apex-guru-run-action"; +import { createSampleCodeAnalyzerDiagnostic } from "../../test-utils"; + +describe("Tests for ApexGuruRunAction", () => { + const sampleUri: vscode.Uri = vscode.Uri.file('/some/file.cls'); + const samplePmdDiag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( + sampleUri, new vscode.Range(0,0,1,0), 'somePmdRule', 'pmd'); + const sampleApexGuruDiag: CodeAnalyzerDiagnostic = createSampleCodeAnalyzerDiagnostic( + sampleUri, new vscode.Range(2,0,2,6), 'someApexGuruRule', 'apexguru'); + + let taskWithProgressRunner: stubs.FakeTaskWithProgressRunner; + let apexGuruService: stubs.StubApexGuruService; + let diagnosticCollection: vscode.DiagnosticCollection; + let diagnosticManager: DiagnosticManager; + let telemetryService: stubs.SpyTelemetryService; + let display: stubs.SpyDisplay; + let apexGuruRunAction: ApexGuruRunAction; + + beforeEach(() => { + taskWithProgressRunner = new stubs.FakeTaskWithProgressRunner(); + apexGuruService = new stubs.StubApexGuruService(); + diagnosticCollection = new FakeDiagnosticCollection(); + diagnosticManager = new DiagnosticManagerImpl(diagnosticCollection); + diagnosticManager.addDiagnostics([samplePmdDiag, sampleApexGuruDiag]); // Start with some sample diagnostics + telemetryService = new stubs.SpyTelemetryService(); + display = new stubs.SpyDisplay(); + apexGuruRunAction = new ApexGuruRunAction( + taskWithProgressRunner, apexGuruService, diagnosticManager, telemetryService, display); + }); + + it("When ApexGuru scan throws error, then display error in error window and send exception telemetry event", async () => { + apexGuruRunAction = new ApexGuruRunAction( + taskWithProgressRunner, new stubs.ThrowingApexGuruService(), diagnosticManager, telemetryService, display); + + await apexGuruRunAction.run('SomeCommandName', sampleUri); + + // First the progress bar should have shown + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory).toHaveLength(1); + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory[0].progressEvent).toEqual({ + message: "Code Analyzer is running ApexGuru analysis." + }); + + // Then the progress bar is removed (no way to unit test that) and an error is shown + expect(display.displayErrorCallHistory).toHaveLength(1); + expect(display.displayErrorCallHistory[0].msg).toEqual('Analysis failed: Sample error message from scan method'); + + // Then telemetry should have been sent + expect(telemetryService.sendExceptionCallHistory).toHaveLength(1); + expect(telemetryService.sendExceptionCallHistory[0].name).toEqual('sfdx__apexguru_file_run_failed'); + expect(telemetryService.sendExceptionCallHistory[0].properties.executedCommand).toEqual('SomeCommandName'); + expect(telemetryService.sendExceptionCallHistory[0].errorMessage).toContain('Error: Sample error message from scan method'); + + // Also validate that we didn't modify the existing diagnostics at all + expect(diagnosticManager.getDiagnosticsForFile(sampleUri)).toEqual([samplePmdDiag, sampleApexGuruDiag]); + }); + + it("When ApexGuru scan results in zero violations, then display this information, send telemetry event, and update diagnostics", async () => { + apexGuruService.scanReturnValue = []; + + await apexGuruRunAction.run('SomeCommandName', sampleUri); + + // First the progress bar should have shown + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory).toHaveLength(2); + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory[0].progressEvent).toEqual({ + message: "Code Analyzer is running ApexGuru analysis." + }); + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory[1].progressEvent).toEqual({ + message: "Code Analyzer is processing results.", + increment: 90 + }); + + // Then the progress bar is removed (no way to unit test that) and info of results is shown + expect(display.displayErrorCallHistory).toHaveLength(0); // Sanity check no errors + expect(display.displayWarningCallHistory).toHaveLength(0); // Sanity check no warnings + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toEqual('Scan complete. 0 violations found.'); + + // Then telemetry should have been sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0].commandName).toEqual("sfdx__apexguru_file_run_complete"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.executedCommand).toEqual("SomeCommandName"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.violationCount).toEqual("0"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.violationsWithSuggestedCodeCount).toEqual("0"); // TODO: This will change soon + + // Also validate that we removed the old apexguru diagnostic(s) but kept the old non-apexguru diagnostic(s) + expect(diagnosticManager.getDiagnosticsForFile(sampleUri)).toEqual([samplePmdDiag]); + }); + + it("When ApexGuru scan results in multiple violations, then display this information, send telemetry event, and update diagnostics", async () => { + const violation1: Violation = { + rule: "SomeRule1", + engine: "apexguru", + message: "dummy message 1", + severity: 3, + locations: [{ + file: sampleUri.fsPath, + startLine: 5, + }], + primaryLocationIndex: 0, + tags: [], + resources: ["https://www.example1.com"] + }; + const violation2: Violation = { + rule: "SomeRule2", + engine: "apexguru", + message: "dummy message 2", + severity: 4, + locations: [{ + file: sampleUri.fsPath, + startLine: 7, + endLine: 9 + }], + primaryLocationIndex: 0, + tags: [], + resources: ["https://www.example2.com"] + } + apexGuruService.scanReturnValue = [violation1, violation2]; + + await apexGuruRunAction.run('SomeCommandName', sampleUri); + + // First the progress bar should have shown + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory).toHaveLength(2); + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory[0].progressEvent).toEqual({ + message: "Code Analyzer is running ApexGuru analysis." + }); + expect(taskWithProgressRunner.progressReporter.reportProgressCallHistory[1].progressEvent).toEqual({ + message: "Code Analyzer is processing results.", + increment: 90 + }); + + // Then the progress bar is removed (no way to unit test that) and info of results is shown + expect(display.displayErrorCallHistory).toHaveLength(0); // Sanity check no errors + expect(display.displayWarningCallHistory).toHaveLength(0); // Sanity check no warnings + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toEqual('Scan complete. 2 violations found.'); + + // Then telemetry should have been sent + expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); + expect(telemetryService.sendCommandEventCallHistory[0].commandName).toEqual("sfdx__apexguru_file_run_complete"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.executedCommand).toEqual("SomeCommandName"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.violationCount).toEqual("2"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.violationsWithSuggestedCodeCount).toEqual("0"); // TODO: This will change soon + + // Also validate that we removed the old apexguru diagnostic(s) but kept the other(s) + const actDiags: readonly CodeAnalyzerDiagnostic[] = diagnosticManager.getDiagnosticsForFile(sampleUri); + const expDiags: CodeAnalyzerDiagnostic[] = [samplePmdDiag, CodeAnalyzerDiagnostic.fromViolation(violation1), + CodeAnalyzerDiagnostic.fromViolation(violation2)]; + expectEquivalentDiagnostics(actDiags, expDiags); + }); + + // TODO: Soon (once we update the response handling) we'll be adding in tests for suggestions and fixes +}); + +function expectEquivalentDiagnostics(actDiags: readonly vscode.Diagnostic[], expDiags: readonly vscode.Diagnostic[]): void { + // Using stringify to check for equivalence because toEqual directly ont he arrays fails due to some of the + // properties (maybe the range) not being the exact same instance. + expect(JSON.stringify(actDiags, null, 2)).toEqual(JSON.stringify(expDiags, null, 2)); +} \ No newline at end of file diff --git a/src/test/unit/lib/apexguru/apex-guru-service.test.ts b/src/test/unit/lib/apexguru/apex-guru-service.test.ts new file mode 100644 index 00000000..23ba2522 --- /dev/null +++ b/src/test/unit/lib/apexguru/apex-guru-service.test.ts @@ -0,0 +1,196 @@ +import * as Constants from "../../../../lib/constants"; +import { ApexGuruService, LiveApexGuruService } from "../../../../lib/apexguru/apex-guru-service"; +import { HttpRequest, OrgConnectionService } from "../../../../lib/external-services/org-connection-service"; +import * as stubs from "../../stubs"; +import { Violation } from "../../../../lib/diagnostics"; + +describe("Tests for LiveApexGuruService", () => { + let orgConnectionService: StubOrgConnectionServiceForApexGuru; + let fileHandler: stubs.StubFileHandler; + let logger: stubs.SpyLogger; + let apexGuruService: ApexGuruService; + + beforeEach(() => { + orgConnectionService = new StubOrgConnectionServiceForApexGuru(); + fileHandler = new stubs.StubFileHandler(); + fileHandler.readFileReturnValue = 'dummyContent'; + logger = new stubs.SpyLogger(); + const maxTimeOutSecs: number = 3; // Defaulting to 3 seconds for worse case scenario, but the below tests shouldn't depend on it + const retryIntervalMillis: number = 5; // Reducing to keep polling based tests fast + apexGuruService = new LiveApexGuruService(orgConnectionService, fileHandler, logger, maxTimeOutSecs, retryIntervalMillis); + }); + + describe("Tests for isApexGuruAvailable", () => { + it("When no org is authed, then return false", async () => { + orgConnectionService.isAuthedReturnValue = false; + expect(await apexGuruService.isApexGuruAvailable()).toEqual(false); + }); + + it('When the ApexGuru validate endpoint does not return success, then return false', async () => { + orgConnectionService.requestReturnValueForAuthValidation = { + status: "failed" + }; + expect(await apexGuruService.isApexGuruAvailable()).toEqual(false); + }); + + it('When the ApexGuru validate endpoint returns success, then return true', async () => { + orgConnectionService.requestReturnValueForAuthValidation = { + status: "SUccesS" // Also testing that we check with case insensitivity to be more robust + }; + expect(await apexGuruService.isApexGuruAvailable()).toEqual(true); + + // Sanity check that the right endpoint was used + expect(orgConnectionService.requestCallHistory).toHaveLength(1); + expect(orgConnectionService.requestCallHistory[0].requestOptions).toEqual({ + method: "GET", + url: "/services/data/v62.0/apexguru/validate" + }); + }); + }); + + describe("Tests for scan", () => { + it('When initial ApexGuru request does not respond with new status, then error', async () => { + orgConnectionService.requestReturnValueForInitialRequest = { + status: "failed", + requestId: "someRequestId" + } + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + 'ApexGuru returned an unexpected response:\n' + + JSON.stringify(orgConnectionService.requestReturnValueForInitialRequest, null, 2)); + }); + + it('When initial ApexGuru request does not respond with requestId, then error', async () => { + orgConnectionService.requestReturnValueForInitialRequest = { + status: "success", + } + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + 'ApexGuru returned an unexpected response:\n' + + JSON.stringify(orgConnectionService.requestReturnValueForInitialRequest, null, 2)); + }); + + it('When ApexGuru polling request responds on first attempt with success, then violations are returned', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "succeSs", report: Buffer.from("[]").toString('base64')} + ]; + + const violations: Violation[] = await apexGuruService.scan('/some/file.cls'); + + // TODO: Currently we are waiting on the new response schema before we finish implementing this unit test + // and at that time we should modify this test to return a few violations + expect(violations).toEqual([]); + }); + + it('When ApexGuru polling request fails on first attempt, then throw error', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "failed", report: 'notUsed' } + ]; + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + 'ApexGuru was unable to analyze the file.'); + }); + + it('When ApexGuru polling request errors on second attempt, then throw error', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "new", report: 'notUsed'}, + {status: "error", report: 'notUsed', message: 'Some error message here'}, + ]; + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + 'ApexGuru returned an unexpected error: Some error message here'); + + // Sanity check: + expect(orgConnectionService.requestCallHistory).toHaveLength(3); // Initial request + 2 polling attempts + }); + + it('When ApexGuru polling request succeeds on third attempt, then violations are returned', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "new", report: 'notUsed'}, + {status: "new", report: 'notUsed'}, + {status: "success", report: Buffer.from("[]").toString('base64')}, + ]; + + const violations: Violation[] = await apexGuruService.scan('/some/file.cls'); + + // TODO: Currently we are waiting on the new response schema before we finish implementing this unit test + expect(violations).toEqual([]); + + // Sanity check: + expect(orgConnectionService.requestCallHistory).toHaveLength(4); // Initial request + 3 polling attempts + }); + + it('When ApexGuru polilng request reaches timeout withot succeeding, then throw error', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "new", report: 'notUsed'}, + {status: "new", report: 'notUsed'}, + {status: "new", report: 'notUsed'}, + {status: "new", report: 'notUsed'} + ]; + + const maxTimeOutSecs: number = 0.1; // Timeout after one tenth of a second + const retryIntervalMillis: number = 50; // Only retry every 50 milliseconds and thus should only have 2 to 3 max attempts + apexGuruService = new LiveApexGuruService(orgConnectionService, fileHandler, logger, maxTimeOutSecs, retryIntervalMillis); + + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + 'Failed to get a successful response from ApexGuru after 0.1 seconds.'); + }); + + it('When ApexGuru response does not contain a status field, then error', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {oops: 3}, + ]; + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + `ApexGuru returned an unexpected error: ApexGuru returned a response without a 'status' field containing a string value.`); + }); + + it('When ApexGuru response has a status field that is not a string, then error', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: 1}, + ]; + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + `ApexGuru returned an unexpected error: ApexGuru returned a response without a 'status' field containing a string value.`); + }); + + it('When ApexGuru response has a report string that is not valid json encoded as base64, then error', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "success", report: Buffer.from("[oops").toString('base64')} + ]; + + await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + `Unable to parse response from ApexGuru.\n\nError:`); + }); + }); +}); + + +export class StubOrgConnectionServiceForApexGuru implements OrgConnectionService { + isAuthedReturnValue: boolean = true; + isAuthed(): boolean { + return this.isAuthedReturnValue; + } + + onOrgChangeCallHistory: {callback: () => void}[] = []; + onOrgChange(callback: () => void): void { + this.onOrgChangeCallHistory.push({callback}); + } + + requestReturnValueForAuthValidation: unknown = {status: "success"}; + requestReturnValueForInitialRequest: unknown = {status: "new", requestId: "someRequestId"}; + requestReturnValuesForPollingRequests: unknown[] = []; + requestCallHistory: {requestOptions: HttpRequest}[] = []; + request(requestOptions: HttpRequest): Promise { + this.requestCallHistory.push({requestOptions}); + if (requestOptions.url === Constants.APEX_GURU_VALIDATE_ENDPOINT) { + return Promise.resolve(this.requestReturnValueForAuthValidation as T); + } else if (requestOptions.url === Constants.APEX_GURU_REQUEST_ENDPOINT && requestOptions.method === 'POST') { + return Promise.resolve(this.requestReturnValueForInitialRequest as T); + } else if (requestOptions.url.startsWith(Constants.APEX_GURU_REQUEST_ENDPOINT) && requestOptions.method === 'GET') { + return Promise.resolve((this.requestReturnValuesForPollingRequests as T[]).shift()); + } + return undefined; + } +} \ No newline at end of file diff --git a/src/test/unit/stubs.ts b/src/test/unit/stubs.ts index f70018f4..8ec9ddf9 100644 --- a/src/test/unit/stubs.ts +++ b/src/test/unit/stubs.ts @@ -15,6 +15,7 @@ import * as semver from "semver"; import {FileHandler} from "../../lib/fs-utils"; import {VscodeWorkspace, WindowManager} from "../../lib/vscode-api"; import {Workspace} from "../../lib/workspace"; +import { ApexGuruService } from "../../lib/apexguru/apex-guru-service"; export class SpyTelemetryService implements TelemetryService { @@ -343,6 +344,11 @@ export class StubFileHandler implements FileHandler { createTempFile(_ext?: string): Promise { return Promise.resolve(this.createTempFileReturnValue); } + + readFileReturnValue: string = ''; + readFile(_file: string): Promise { + return Promise.resolve(this.readFileReturnValue); + } } export class SpyWindowManager implements WindowManager { @@ -355,5 +361,26 @@ export class SpyWindowManager implements WindowManager { showExternalUrl(url: string): void { this.showExternalUrlCallHistory.push({url}); } +} + +export class StubApexGuruService implements ApexGuruService { + isApexGuruAvailableReturnValue: boolean = true; + isApexGuruAvailable(): Promise { + return Promise.resolve(this.isApexGuruAvailableReturnValue); + } + scanReturnValue: Violation[] = []; + scan(_absFileToScan: string): Promise { + return Promise.resolve(this.scanReturnValue); + } } + +export class ThrowingApexGuruService implements ApexGuruService { + isApexGuruAvailable(): Promise { + throw new Error("Sample error message from isApexGuruAvailable method"); + } + scan(_absFileToScan: string): Promise { + throw new Error("Sample error message from scan method"); + } + +} \ No newline at end of file From 5eb659159ed5aba41e1818c516ab3649bf1ac1ab Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:59:53 -0400 Subject: [PATCH 13/17] NEW: @W-19335794@: Warn if users select multiple files to scan with ApexGuru (#272) Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> --- package.json | 2 +- src/extension.ts | 11 +++++++---- src/lib/messages.ts | 4 ++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 7b95d77f..9aa140a1 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ }, { "command": "sfca.runApexGuruAnalysisOnSelectedFile", - "when": "sfca.extensionActivated && sfca.apexGuruEnabled && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/" + "when": "sfca.extensionActivated && sfca.apexGuruEnabled && explorerResourceIsFolder == false && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/" } ] }, diff --git a/src/extension.ts b/src/extension.ts index a7ae5e75..b29ad2a5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -256,10 +256,13 @@ export async function activate(context: vscode.ExtensionContext): Promise - await apexGuruRunAction.run(Constants.COMMAND_RUN_APEX_GURU_ON_FILE, - multiSelect && multiSelect.length > 0 ? multiSelect[0] : selection)); // TODO: We should somehow restrict multi-select here. We only use the first file right now + // COMMAND_RUN_APEX_GURU_ON_FILE: Invokable by 'explorer/context' menu only when: "sfca.apexGuruEnabled && explorerResourceIsFolder == false && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/" + registerCommand(Constants.COMMAND_RUN_APEX_GURU_ON_FILE, async (selection: vscode.Uri, multiSelect?: vscode.Uri[]) => { + if (multiSelect?.length > 1) { + display.displayWarning(messages.apexGuru.warnings.canOnlyScanOneFile(selection.fsPath)); + } + await apexGuruRunAction.run(Constants.COMMAND_RUN_APEX_GURU_ON_FILE, selection); + }); // COMMAND_RUN_APEX_GURU_ON_ACTIVE_FILE: Invokable by 'commandPalette' and 'editor/context' menus only when: "sfca.apexGuruEnabled && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/" registerCommand(Constants.COMMAND_RUN_APEX_GURU_ON_ACTIVE_FILE, async () => { diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 67176ea0..85f23a2c 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -24,6 +24,10 @@ export const messages = { apexGuru: { runningAnalysis: "Code Analyzer is running ApexGuru analysis.", finishedScan: (violationCount: number) => `Scan complete. ${violationCount} violations found.`, + warnings: { + canOnlyScanOneFile: (file: string) => + `ApexGuru can scan only one file at a time. Ignoring the other files in your multi-selection and scanning only this file: ${file}.` + }, errors: { unableToAnalyzeFile: 'ApexGuru was unable to analyze the file.', returnedUnexpectedResponse: (responseStr: string) => From 8508d2e8d7f145f906adae826f09c858588f098b Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:16:06 -0400 Subject: [PATCH 14/17] CHANGE: @W-19053527@: Handle new ApexGuru responses (#273) --- src/lib/apexguru/apex-guru-run-action.ts | 6 +- src/lib/apexguru/apex-guru-service.ts | 159 ++++++++------- src/lib/constants.ts | 6 - .../org-connection-service.ts | 16 ++ src/lib/messages.ts | 6 +- .../lib/apexguru/apex-guru-run-action.test.ts | 68 ++++++- .../lib/apexguru/apex-guru-service.test.ts | 188 +++++++++++++++--- 7 files changed, 330 insertions(+), 119 deletions(-) diff --git a/src/lib/apexguru/apex-guru-run-action.ts b/src/lib/apexguru/apex-guru-run-action.ts index 7ce6a566..9ca5a276 100644 --- a/src/lib/apexguru/apex-guru-run-action.ts +++ b/src/lib/apexguru/apex-guru-run-action.ts @@ -55,9 +55,9 @@ export class ApexGuruRunAction { this.telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS, { executedCommand: commandName, duration: (Date.now() - startTime).toString(), - violationCount: violations.length.toString(), - // TODO: This telemetry might change in the near future so that we can track the number of suggestions and fixes - violationsWithSuggestedCodeCount: violations.filter(v => v.suggestions?.length > 0).length.toString() + numViolations: violations.length.toString(), + numViolationsWithSuggestions: violations.filter(v => v.suggestions?.length > 0).length.toString(), + numViolationsWithFixes: violations.filter(v => v.fixes?.length > 0).length.toString() }); } catch (err) { this.display.displayError(messages.error.analysisFailedGenerator(getErrorMessage(err))); diff --git a/src/lib/apexguru/apex-guru-service.ts b/src/lib/apexguru/apex-guru-service.ts index 5ac4572b..fbc79cd1 100644 --- a/src/lib/apexguru/apex-guru-service.ts +++ b/src/lib/apexguru/apex-guru-service.ts @@ -5,8 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import * as Constants from '../constants'; -import {CodeLocation, Violation} from '../diagnostics'; +import {CodeLocation, Fix, Suggestion, Violation} from '../diagnostics'; import {Logger} from "../logger"; import {getErrorMessage, indent} from '../utils'; import {HttpMethods, HttpRequest, OrgConnectionService} from '../external-services/org-connection-service'; @@ -14,6 +13,8 @@ import {FileHandler} from '../fs-utils'; import { messages } from '../messages'; export const APEX_GURU_ENGINE_NAME: string = 'apexguru'; +const APEX_GURU_MAX_TIMEOUT_SECONDS = 60; +const APEX_GURU_RETRY_INTERVAL_MILLIS = 1000; const RESPONSE_STATUS = { NEW: "new", @@ -37,8 +38,8 @@ export class LiveApexGuruService implements ApexGuruService { orgConnectionService: OrgConnectionService, fileHandler: FileHandler, logger: Logger, - maxTimeoutSeconds: number = Constants.APEX_GURU_MAX_TIMEOUT_SECONDS, - retryIntervalMillis: number = Constants.APEX_GURU_RETRY_INTERVAL_MILLIS) { + maxTimeoutSeconds: number = APEX_GURU_MAX_TIMEOUT_SECONDS, + retryIntervalMillis: number = APEX_GURU_RETRY_INTERVAL_MILLIS) { this.orgConnectionService = orgConnectionService; this.fileHandler = fileHandler; this.logger = logger; @@ -50,7 +51,7 @@ export class LiveApexGuruService implements ApexGuruService { if (!this.orgConnectionService.isAuthed()) { return false; } - const response: ApexGuruResponse = await this.request('GET', Constants.APEX_GURU_VALIDATE_ENDPOINT); + const response: ApexGuruResponse = await this.request('GET', await this.getValidateEndpoint()); return response.status === RESPONSE_STATUS.SUCCESS; } @@ -59,16 +60,22 @@ export class LiveApexGuruService implements ApexGuruService { const requestId = await this.initiateRequest(fileContent); this.logger.debug(`Initialized ApexGuru Analysis with Request Id: ${requestId}`); const queryResponse: ApexGuruQueryResponse = await this.waitForResponse(requestId); - this.logger.debug(`ApexGuru Analysis completed for Request Id: ${requestId}`); - const reports: ApexGuruReport[] = toReportArray(queryResponse); - return reports.map(r => toViolation(r, absFileToScan)); + const payloadStr: string = decodeFromBase64(queryResponse.report); + this.logger.debug(`ApexGuru Analysis completed for Request Id: ${requestId}\n\nDecoded Response Payload:\n${payloadStr}`); + const apexGuruViolations: ApexGuruViolation[] = parsePayload(payloadStr); + return apexGuruViolations.map(v => toViolation(v, absFileToScan)); } private async initiateRequest(fileContent: string): Promise { - const base64EncodedContent = Buffer.from(fileContent).toString('base64'); - const response: ApexGuruInitialResponse = await this.request('POST', Constants.APEX_GURU_REQUEST_ENDPOINT, - JSON.stringify({classContent: base64EncodedContent})); - if (!response.requestId || response.status != RESPONSE_STATUS.NEW) { + const requestBody: ApexGuruRequestBody = { + classContent: encodeToBase64(fileContent) + }; + const response: ApexGuruInitialResponse = await this.request('POST', await this.getRequestEndpoint(), + JSON.stringify(requestBody)); + + if (response.status == RESPONSE_STATUS.FAILED) { + throw new Error(messages.apexGuru.errors.unableToAnalyzeFile(response.message ?? '')); + } else if (!response.requestId || response.status != RESPONSE_STATUS.NEW) { throw Error(messages.apexGuru.errors.returnedUnexpectedResponse(JSON.stringify(response, null, 2))); } return response.requestId; @@ -81,11 +88,11 @@ export class LiveApexGuruService implements ApexGuruService { if (queryResponse) { // After the first attempt, we pause each time between requests await new Promise(resolve => setTimeout(resolve, this.retryIntervalMillis)); } - queryResponse = await this.request('GET', `${Constants.APEX_GURU_REQUEST_ENDPOINT}/${requestId}`); + queryResponse = await this.request('GET', await this.getRequestEndpoint(requestId)); if (queryResponse.status === RESPONSE_STATUS.SUCCESS && queryResponse.report) { return queryResponse; - } else if (queryResponse.status === RESPONSE_STATUS.FAILED) { // TODO: I would love a failure message - but the response's report just gives back the file content and nothing else. - throw new Error(messages.apexGuru.errors.unableToAnalyzeFile); + } else if (queryResponse.status === RESPONSE_STATUS.FAILED) { + throw new Error(messages.apexGuru.errors.unableToAnalyzeFile(queryResponse.message ?? '')); } else if (queryResponse.status === RESPONSE_STATUS.ERROR && queryResponse.message) { throw new Error(messages.apexGuru.errors.returnedUnexpectedError(queryResponse.message)); } @@ -117,73 +124,73 @@ export class LiveApexGuruService implements ApexGuruService { this.logger.trace('Call to ApexGuru Service failed:' + getErrorMessage(err)); return { status: RESPONSE_STATUS.ERROR, - message: getErrorMessage(err) + message: getErrorMessage(err), } as T; } } + + private async getValidateEndpoint(): Promise { + const apiVersion: string = await this.orgConnectionService.getApiVersion(); + return `/services/data/v${apiVersion}/apexguru/validate`; + } + + private async getRequestEndpoint(requestId?: string): Promise { + const apiVersion: string = await this.orgConnectionService.getApiVersion(); + return `/services/data/v${apiVersion}/apexguru/request` + (requestId ? `/${requestId}` : ''); + } } -export function toReportArray(response: ApexGuruQueryResponse): ApexGuruReport[] { - // TODO: This will change soon enough - once we receive the actual response - const report: string = Buffer.from(response.report, 'base64').toString('utf-8'); +export function parsePayload(payloadStr: string): ApexGuruViolation[] { try { - return JSON.parse(report) as ApexGuruReport[]; + return JSON.parse(payloadStr) as ApexGuruViolation[]; } catch (err) { - throw new Error(`Unable to parse response from ApexGuru.\n\n` + - `Error:\n${indent(getErrorMessage(err))}\n\nDecoded report:\n${indent(report)}`); + throw new Error(messages.apexGuru.errors.unableToParsePayload(indent(getErrorMessage(err)))); } } -function toViolation(parsed: ApexGuruReport, file: string): Violation { - // IMPORTANT: AS OF 08/13/2024 THIS ALL FAILS IN PRODUCTION BECAUSE THE NEW ApexGuru Service NOW HAS A NEW - // RESPONSE. TODO: W-19053527 - const encodedCodeAfter = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_after')?.value - ?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_after')?.value - ?? ''; - const suggestedCode: string = Buffer.from(encodedCodeAfter, 'base64').toString('utf8'); - - const lineNumber = parseInt(parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'line_number')?.value); - - const violationLocation: CodeLocation = { - file: file, - startLine: lineNumber, - startColumn: 1 +function toViolation(apexGuruViolation: ApexGuruViolation, file: string): Violation { + const codeAnalyzerViolation: Violation = { + rule: apexGuruViolation.rule, + engine: APEX_GURU_ENGINE_NAME, + message: apexGuruViolation.message, + severity: apexGuruViolation.severity, + locations: apexGuruViolation.locations.map(l => addFile(l, file)), + primaryLocationIndex: apexGuruViolation.primaryLocationIndex, + tags: [], // Currently not used + resources: apexGuruViolation.resources ?? [], + suggestions: apexGuruViolation.suggestions?.map(s => { + s.location = addFile(s.location, file); + return s; + }), + fixes: apexGuruViolation.fixes?.map(f => { + f.location = addFile(f.location, file); + return f; + }) }; + return codeAnalyzerViolation; +} - const violation: Violation = { - rule: parsed.type, - engine: APEX_GURU_ENGINE_NAME, - message: parsed.value, - severity: 1, // TODO: Should this really be critical level violation? This seems off. - locations: [violationLocation], - primaryLocationIndex: 0, - tags: [], - resources: [ - 'https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5' - ] +function addFile(apexGuruLocation: CodeLocation, filePath: string): CodeLocation { + return { + ...apexGuruLocation, + file: filePath }; +} - // TODO: Soon we'll be receiving a different looking payload which will help us differentiate between fixes and suggestions. - // For now, we are going to treat suggestedCode as a fix and a suggestion (as the current pilot code does) - if (suggestedCode.length > 0) { - violation.fixes = [ - { - location: violationLocation, - fixedCode: `/*\n//ApexGuru Suggestions: \n${suggestedCode}\n*/` - } - ] - violation.suggestions = [ - { - location: violationLocation, - // This message is temporary and will be improved as we get a better response back and unify the suggestions experience - message: suggestedCode - } - ] - } - return violation; +function encodeToBase64(value: string): string { + return Buffer.from(value).toString('base64'); } +function decodeFromBase64(value: string): string { + return Buffer.from(value, 'base64').toString('utf-8'); +} + + +type ApexGuruRequestBody = { + // Must be base64 encoded + classContent: string +} type ApexGuruResponse = { status: string; @@ -195,17 +202,21 @@ type ApexGuruInitialResponse = ApexGuruResponse & { } type ApexGuruQueryResponse = ApexGuruResponse & { + // Is returned with base64 encoding report: string; } -type ApexGuruProperty = { - name: string; - value: string; -}; +type ApexGuruViolation = { + rule: string; + message: string; + + // Note that none of these location objects from ApexGuru will have a "file" field on it + locations: CodeLocation[]; + primaryLocationIndex: number; + severity: number; + resources: string[]; -type ApexGuruReport = { - id: string; - type: string; - value: string; - properties: ApexGuruProperty[]; + // Note that each suggestion and fix location from ApexGuru will not have a "file" field on it + suggestions?: Suggestion[]; + fixes?: Fix[]; } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index abcbb430..016e795e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -55,12 +55,6 @@ export const MINIMUM_REQUIRED_VERSION_CORE_EXTENSION = '58.4.1'; export const RECOMMENDED_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0'; export const ABSOLUTE_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0-beta.0'; -// apex guru APIS -export const APEX_GURU_VALIDATE_ENDPOINT = '/services/data/v62.0/apexguru/validate' -export const APEX_GURU_REQUEST_ENDPOINT = '/services/data/v62.0/apexguru/request' -export const APEX_GURU_MAX_TIMEOUT_SECONDS = 60; -export const APEX_GURU_RETRY_INTERVAL_MILLIS = 1000; - // Context variables (dynamically set but consumed by the "when" conditions in the package.json "contributes" sections) export const CONTEXT_VAR_EXTENSION_ACTIVATED = 'sfca.extensionActivated'; export const CONTEXT_VAR_APEX_GURU_ENABLED = 'sfca.apexGuruEnabled'; diff --git a/src/lib/external-services/org-connection-service.ts b/src/lib/external-services/org-connection-service.ts index 2aac0eda..bab5aa38 100644 --- a/src/lib/external-services/org-connection-service.ts +++ b/src/lib/external-services/org-connection-service.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; export interface OrgConnectionService { isAuthed(): boolean; + getApiVersion(): Promise; onOrgChange(callback: () => void): void; request(requestOptions: HttpRequest): Promise; } @@ -15,9 +16,15 @@ export class NoOpOrgConnectionService implements OrgConnectionService { isAuthed(): boolean { return false; } + + getApiVersion(): Promise { + throw new Error(`Cannot get the api verison because no org is authed.`); + } + onOrgChange(_callback: () => void): void { // No-op } + request(requestOptions: HttpRequest): Promise { throw new Error(`Cannot make the following request because no org is authed:\n${JSON.stringify(requestOptions, null, 2)}`); } @@ -34,6 +41,15 @@ export class LiveOrgConnectionService implements OrgConnectionService { return this.workpaceContext.orgId?.length > 0; } + async getApiVersion(): Promise { + if (!this.isAuthed()) { + throw new Error(`Cannot get the api verison because no org is authed.`); + } + const connection: Connection = await this.workpaceContext.getConnection(); + return connection.getApiVersion(); + } + + onOrgChange(callback: (event: OrgUserInfo) => void): void { this.workpaceContext.onOrgChange(callback); } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 85f23a2c..9e3957ab 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -29,7 +29,7 @@ export const messages = { `ApexGuru can scan only one file at a time. Ignoring the other files in your multi-selection and scanning only this file: ${file}.` }, errors: { - unableToAnalyzeFile: 'ApexGuru was unable to analyze the file.', + unableToAnalyzeFile: (reason: string) => `ApexGuru was unable to analyze the file. ${reason}`, returnedUnexpectedResponse: (responseStr: string) => `ApexGuru returned an unexpected response:\n${responseStr}`, returnedUnexpectedError: (errMsg: string) => `ApexGuru returned an unexpected error: ${errMsg}`, @@ -37,7 +37,9 @@ export const messages = { `Failed to get a successful response from ApexGuru after ${maxSeconds} seconds.\n` + `Last response:\n${lastResponse}`, expectedResponseToContainStatusField: (responseStr: string) => - `ApexGuru returned a response without a 'status' field containing a string value. Instead received:\n${responseStr}` + `ApexGuru returned a response without a 'status' field containing a string value. Instead received:\n${responseStr}`, + unableToParsePayload: (errMsg: string) => + `Unable to parse the payload from the response from ApexGuru. Error:\n${errMsg}` } }, info: { diff --git a/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts b/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts index 514181b0..5b9bcbbf 100644 --- a/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts +++ b/src/test/unit/lib/apexguru/apex-guru-run-action.test.ts @@ -84,8 +84,9 @@ describe("Tests for ApexGuruRunAction", () => { expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); expect(telemetryService.sendCommandEventCallHistory[0].commandName).toEqual("sfdx__apexguru_file_run_complete"); expect(telemetryService.sendCommandEventCallHistory[0].properties.executedCommand).toEqual("SomeCommandName"); - expect(telemetryService.sendCommandEventCallHistory[0].properties.violationCount).toEqual("0"); - expect(telemetryService.sendCommandEventCallHistory[0].properties.violationsWithSuggestedCodeCount).toEqual("0"); // TODO: This will change soon + expect(telemetryService.sendCommandEventCallHistory[0].properties.numViolations).toEqual("0"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.numViolationsWithSuggestions).toEqual("0"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.numViolationsWithFixes).toEqual("0"); // Also validate that we removed the old apexguru diagnostic(s) but kept the old non-apexguru diagnostic(s) expect(diagnosticManager.getDiagnosticsForFile(sampleUri)).toEqual([samplePmdDiag]); @@ -103,7 +104,16 @@ describe("Tests for ApexGuruRunAction", () => { }], primaryLocationIndex: 0, tags: [], - resources: ["https://www.example1.com"] + resources: ["https://www.example1.com"], + suggestions: [ + { + message: "sample suggestion", + location: { + file: sampleUri.fsPath, + startLine: 5 + } + } + ] }; const violation2: Violation = { rule: "SomeRule2", @@ -119,7 +129,46 @@ describe("Tests for ApexGuruRunAction", () => { tags: [], resources: ["https://www.example2.com"] } - apexGuruService.scanReturnValue = [violation1, violation2]; + const violation3: Violation = { + rule: "SomeRule3", + engine: "apexguru", + message: "dummy message 3", + severity: 4, + locations: [{ + file: sampleUri.fsPath, + startLine: 7, + endLine: 9 + }], + primaryLocationIndex: 0, + tags: [], + resources: ["https://www.example3.com"], + suggestions: [ + { + message: "sample suggestion 1", + location: { + file: sampleUri.fsPath, + startLine: 7 + } + }, + { + message: "sample suggestion 2", + location: { + file: sampleUri.fsPath, + startLine: 8 + } + }, + ], + fixes: [ + { + fixedCode: "SomeFixedCode", + location: { + file: sampleUri.fsPath, + startLine: 9 + } + } + ] + } + apexGuruService.scanReturnValue = [violation1, violation2, violation3]; await apexGuruRunAction.run('SomeCommandName', sampleUri); @@ -137,23 +186,22 @@ describe("Tests for ApexGuruRunAction", () => { expect(display.displayErrorCallHistory).toHaveLength(0); // Sanity check no errors expect(display.displayWarningCallHistory).toHaveLength(0); // Sanity check no warnings expect(display.displayInfoCallHistory).toHaveLength(1); - expect(display.displayInfoCallHistory[0].msg).toEqual('Scan complete. 2 violations found.'); + expect(display.displayInfoCallHistory[0].msg).toEqual('Scan complete. 3 violations found.'); // Then telemetry should have been sent expect(telemetryService.sendCommandEventCallHistory).toHaveLength(1); expect(telemetryService.sendCommandEventCallHistory[0].commandName).toEqual("sfdx__apexguru_file_run_complete"); expect(telemetryService.sendCommandEventCallHistory[0].properties.executedCommand).toEqual("SomeCommandName"); - expect(telemetryService.sendCommandEventCallHistory[0].properties.violationCount).toEqual("2"); - expect(telemetryService.sendCommandEventCallHistory[0].properties.violationsWithSuggestedCodeCount).toEqual("0"); // TODO: This will change soon + expect(telemetryService.sendCommandEventCallHistory[0].properties.numViolations).toEqual("3"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.numViolationsWithSuggestions).toEqual("2"); + expect(telemetryService.sendCommandEventCallHistory[0].properties.numViolationsWithFixes).toEqual("1"); // Also validate that we removed the old apexguru diagnostic(s) but kept the other(s) const actDiags: readonly CodeAnalyzerDiagnostic[] = diagnosticManager.getDiagnosticsForFile(sampleUri); const expDiags: CodeAnalyzerDiagnostic[] = [samplePmdDiag, CodeAnalyzerDiagnostic.fromViolation(violation1), - CodeAnalyzerDiagnostic.fromViolation(violation2)]; + CodeAnalyzerDiagnostic.fromViolation(violation2), CodeAnalyzerDiagnostic.fromViolation(violation3)]; expectEquivalentDiagnostics(actDiags, expDiags); }); - - // TODO: Soon (once we update the response handling) we'll be adding in tests for suggestions and fixes }); function expectEquivalentDiagnostics(actDiags: readonly vscode.Diagnostic[], expDiags: readonly vscode.Diagnostic[]): void { diff --git a/src/test/unit/lib/apexguru/apex-guru-service.test.ts b/src/test/unit/lib/apexguru/apex-guru-service.test.ts index 23ba2522..28b0bc13 100644 --- a/src/test/unit/lib/apexguru/apex-guru-service.test.ts +++ b/src/test/unit/lib/apexguru/apex-guru-service.test.ts @@ -1,10 +1,123 @@ -import * as Constants from "../../../../lib/constants"; import { ApexGuruService, LiveApexGuruService } from "../../../../lib/apexguru/apex-guru-service"; import { HttpRequest, OrgConnectionService } from "../../../../lib/external-services/org-connection-service"; import * as stubs from "../../stubs"; import { Violation } from "../../../../lib/diagnostics"; describe("Tests for LiveApexGuruService", () => { + const sampleFile: string = '/some/file.cls'; + + const sampleApexGuruPayload = [ + { + "rule": "SchemaGetGlobalDescribeNotEfficient", + "message": "Avoid using Schema.getGlobalDescribe() in Apex. This method causes unnecessary overhead, decreases performance, and increases the latency of the associated entry point.", + "locations": [ + { + "startLine": 4, + "comment": "api_class.processAccountsAndContacts" + } + ], + "primaryLocationIndex": 0, + "resources": [ + "https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_schema_getglobaldescribe_not_efficient.htm&type=5" + ], + "severity": 2, + "fixes": [ + { + "location": { + "startLine": 4, + "startColumn": 8, + "comment": "api_class.processAccountsAndContacts" + }, + "fixedCode": "Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();", + } + ] + }, + { + "rule": "SoqlInALoop", + "message": "You're calling an expensive SOQL in a loop, which can cause performance issues. Optimize your SOQL to call once and reuse the return value.", + "locations": [ + { + "startLine": 7, + "comment": "api_class.processAccountsAndContacts" + } + ], + "primaryLocationIndex": 0, + "resources": [ + "https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_soql_in_loop.htm&type=5" + ], + "severity": 3, + "suggestions": [ + { + "location": { + "startLine": 7, + "comment": "api_class.processAccountsAndContacts" + }, + "message": "Sample suggestion message", + } + ] + } + ]; + + const expectedViolations: Violation[] = [ + { + rule: "SchemaGetGlobalDescribeNotEfficient", + engine: "apexguru", + message: "Avoid using Schema.getGlobalDescribe() in Apex. This method causes unnecessary overhead, decreases performance, and increases the latency of the associated entry point.", + severity: 2, + locations: [ + { + file: sampleFile, + startLine: 4, + comment: "api_class.processAccountsAndContacts", + } + ], + primaryLocationIndex: 0, + tags: [], + resources: [ + "https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_schema_getglobaldescribe_not_efficient.htm&type=5" + ], + fixes: [ + { + location: { + file: sampleFile, + startLine: 4, + startColumn: 8, + comment: "api_class.processAccountsAndContacts", + }, + fixedCode: "Schema.DescribeSObjectResult opportunityDescribe = Opportunity.sObjectType.getDescribe();", + } + ] + }, + { + rule: "SoqlInALoop", + engine: "apexguru", + message: "You're calling an expensive SOQL in a loop, which can cause performance issues. Optimize your SOQL to call once and reuse the return value.", + severity: 3, + locations: [ + { + file: sampleFile, + startLine: 7, + comment: "api_class.processAccountsAndContacts" + } + ], + primaryLocationIndex: 0, + tags: [], + resources: [ + "https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_soql_in_loop.htm&type=5" + ], + suggestions: [ + { + location: { + file: sampleFile, + startLine: 7, + comment: "api_class.processAccountsAndContacts", + }, + message: "Sample suggestion message", + } + ] + } + ]; + let orgConnectionService: StubOrgConnectionServiceForApexGuru; let fileHandler: stubs.StubFileHandler; let logger: stubs.SpyLogger; @@ -43,19 +156,29 @@ describe("Tests for LiveApexGuruService", () => { expect(orgConnectionService.requestCallHistory).toHaveLength(1); expect(orgConnectionService.requestCallHistory[0].requestOptions).toEqual({ method: "GET", - url: "/services/data/v62.0/apexguru/validate" + url: "/services/data/v64.0/apexguru/validate" }); }); }); describe("Tests for scan", () => { - it('When initial ApexGuru request does not respond with new status, then error', async () => { + it('When ApexGuru responds to initial with failed status, then error', async () => { orgConnectionService.requestReturnValueForInitialRequest = { status: "failed", + message: "Some Failure Message" + } + + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( + 'ApexGuru was unable to analyze the file. Some Failure Message'); + }); + + it('When ApexGuru responds to initial without a new or failed status, then error', async () => { + orgConnectionService.requestReturnValueForInitialRequest = { + status: "other", requestId: "someRequestId" } - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( 'ApexGuru returned an unexpected response:\n' + JSON.stringify(orgConnectionService.requestReturnValueForInitialRequest, null, 2)); }); @@ -65,21 +188,20 @@ describe("Tests for LiveApexGuruService", () => { status: "success", } - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( 'ApexGuru returned an unexpected response:\n' + JSON.stringify(orgConnectionService.requestReturnValueForInitialRequest, null, 2)); }); it('When ApexGuru polling request responds on first attempt with success, then violations are returned', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ - {status: "succeSs", report: Buffer.from("[]").toString('base64')} + {status: "succeSs", report: Buffer.from(JSON.stringify(sampleApexGuruPayload)).toString('base64')} ]; - const violations: Violation[] = await apexGuruService.scan('/some/file.cls'); + const violations: Violation[] = await apexGuruService.scan(sampleFile); - // TODO: Currently we are waiting on the new response schema before we finish implementing this unit test - // and at that time we should modify this test to return a few violations - expect(violations).toEqual([]); + expect(violations).toEqual(expectedViolations); }); it('When ApexGuru polling request fails on first attempt, then throw error', async () => { @@ -87,7 +209,7 @@ describe("Tests for LiveApexGuruService", () => { {status: "failed", report: 'notUsed' } ]; - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( 'ApexGuru was unable to analyze the file.'); }); @@ -97,23 +219,37 @@ describe("Tests for LiveApexGuruService", () => { {status: "error", report: 'notUsed', message: 'Some error message here'}, ]; - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( 'ApexGuru returned an unexpected error: Some error message here'); // Sanity check: expect(orgConnectionService.requestCallHistory).toHaveLength(3); // Initial request + 2 polling attempts }); - it('When ApexGuru polling request succeeds on third attempt, then violations are returned', async () => { + it('When ApexGuru polling request succeeds on third attempt with violations, then violations are returned', async () => { + orgConnectionService.requestReturnValuesForPollingRequests = [ + {status: "new", report: 'notUsed'}, + {status: "new", report: 'notUsed'}, + {status: "success", report: Buffer.from(JSON.stringify(sampleApexGuruPayload)).toString('base64')}, + ]; + + const violations: Violation[] = await apexGuruService.scan(sampleFile); + + expect(violations).toEqual(expectedViolations); + + // Sanity check: + expect(orgConnectionService.requestCallHistory).toHaveLength(4); // Initial request + 3 polling attempts + }); + + it('When ApexGuru polling request succeeds on third attempt with zero violations, then zero violations are returned', async () => { orgConnectionService.requestReturnValuesForPollingRequests = [ {status: "new", report: 'notUsed'}, {status: "new", report: 'notUsed'}, {status: "success", report: Buffer.from("[]").toString('base64')}, ]; - const violations: Violation[] = await apexGuruService.scan('/some/file.cls'); + const violations: Violation[] = await apexGuruService.scan(sampleFile); - // TODO: Currently we are waiting on the new response schema before we finish implementing this unit test expect(violations).toEqual([]); // Sanity check: @@ -133,7 +269,7 @@ describe("Tests for LiveApexGuruService", () => { apexGuruService = new LiveApexGuruService(orgConnectionService, fileHandler, logger, maxTimeOutSecs, retryIntervalMillis); - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( 'Failed to get a successful response from ApexGuru after 0.1 seconds.'); }); @@ -142,7 +278,7 @@ describe("Tests for LiveApexGuruService", () => { {oops: 3}, ]; - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( `ApexGuru returned an unexpected error: ApexGuru returned a response without a 'status' field containing a string value.`); }); @@ -151,7 +287,7 @@ describe("Tests for LiveApexGuruService", () => { {status: 1}, ]; - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( `ApexGuru returned an unexpected error: ApexGuru returned a response without a 'status' field containing a string value.`); }); @@ -160,8 +296,8 @@ describe("Tests for LiveApexGuruService", () => { {status: "success", report: Buffer.from("[oops").toString('base64')} ]; - await expect(apexGuruService.scan('/some/file.cls')).rejects.toThrow( - `Unable to parse response from ApexGuru.\n\nError:`); + await expect(apexGuruService.scan(sampleFile)).rejects.toThrow( + `Unable to parse the payload from the response from ApexGuru. Error:\n Unexpected token`); }); }); }); @@ -178,19 +314,23 @@ export class StubOrgConnectionServiceForApexGuru implements OrgConnectionService this.onOrgChangeCallHistory.push({callback}); } + getApiVersion(): Promise { + return Promise.resolve('64.0'); + } + requestReturnValueForAuthValidation: unknown = {status: "success"}; requestReturnValueForInitialRequest: unknown = {status: "new", requestId: "someRequestId"}; requestReturnValuesForPollingRequests: unknown[] = []; requestCallHistory: {requestOptions: HttpRequest}[] = []; request(requestOptions: HttpRequest): Promise { this.requestCallHistory.push({requestOptions}); - if (requestOptions.url === Constants.APEX_GURU_VALIDATE_ENDPOINT) { + if (requestOptions.url.endsWith('/apexguru/validate')) { return Promise.resolve(this.requestReturnValueForAuthValidation as T); - } else if (requestOptions.url === Constants.APEX_GURU_REQUEST_ENDPOINT && requestOptions.method === 'POST') { + } else if (requestOptions.url.endsWith('/apexguru/request') && requestOptions.method === 'POST') { return Promise.resolve(this.requestReturnValueForInitialRequest as T); - } else if (requestOptions.url.startsWith(Constants.APEX_GURU_REQUEST_ENDPOINT) && requestOptions.method === 'GET') { + } else if (requestOptions.url.includes('/apexguru/request/') && requestOptions.method === 'GET') { return Promise.resolve((this.requestReturnValuesForPollingRequests as T[]).shift()); } - return undefined; + throw new Error(`Unhandled request: ${JSON.stringify(requestOptions)}`); } } \ No newline at end of file From 9ae9e49f3e600e222bfaebcc637a6eb44421be36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:12:54 +0000 Subject: [PATCH 15/17] Preparing for v1.10.0 release. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b79e746..089514c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sfdx-code-analyzer-vscode", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sfdx-code-analyzer-vscode", - "version": "1.9.0", + "version": "1.10.0", "license": "BSD-3-Clause", "dependencies": { "@salesforce/vscode-service-provider": "^1.5.0", diff --git a/package.json b/package.json index 9aa140a1..3e1f1ffa 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "color": "#ECECEC", "theme": "light" }, - "version": "1.9.0", + "version": "1.10.0", "publisher": "salesforce", "license": "BSD-3-Clause", "engines": { From 8185f807c7ffbbbdb6a94754463938690e976066 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:29:13 -0400 Subject: [PATCH 16/17] FIX: @W-19420771@: Workaround PMD Suppress whitespace issue (#274) --- src/lib/pmd/pmd-suppressions-code-action-provider.ts | 2 +- .../unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/pmd/pmd-suppressions-code-action-provider.ts b/src/lib/pmd/pmd-suppressions-code-action-provider.ts index ff06f66b..6eb11278 100644 --- a/src/lib/pmd/pmd-suppressions-code-action-provider.ts +++ b/src/lib/pmd/pmd-suppressions-code-action-provider.ts @@ -90,7 +90,7 @@ function generateClassLevelSuppression(document: vscode.TextDocument, diag: Code const existingSuppressionTags = suppressionMatch[1].split(',').map(rule => rule.trim()); if (!existingSuppressionTags.includes(suppressionTag)) { // If the rule is not present, add it to the existing @SuppressWarnings - const updatedSuppressionTagsList = [...existingSuppressionTags, suppressionTag].join(', '); + const updatedSuppressionTagsList = [...existingSuppressionTags, suppressionTag].join(','); const updatedSuppression = `@SuppressWarnings('${updatedSuppressionTagsList}')`; const suppressionStartPosition = document.positionAt(classText.indexOf(suppressionMatch[0])); const suppressionEndPosition = document.positionAt(classText.indexOf(suppressionMatch[0]) + suppressionMatch[0].length); diff --git a/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts b/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts index b8f72f1f..652f5476 100644 --- a/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts +++ b/src/test/unit/lib/pmd/pmd-suppressions-code-action-provider.test.ts @@ -116,7 +116,7 @@ describe('PMDSupressionsCodeActionProvider Tests', () => { const classEdits: vscode.TextEdit[] = codeActions[1].edit.get(sampleApexUri); expect(classEdits).toHaveLength(1); expect(classEdits[0].range).toEqual(new vscode.Range(0, 0, 0, 40)); - expect(classEdits[0].newText).toEqual("@SuppressWarnings('PMD.EmptyCatchBlock, PMD.ApexDoc')"); + expect(classEdits[0].newText).toEqual("@SuppressWarnings('PMD.EmptyCatchBlock,PMD.ApexDoc')"); expect(codeActions[1].command.command).toEqual("sfca.removeDiagnosticsOnSelectedFile"); // TODO: This is wrong. It should only clear the PMD diagnostics within the class instead of all diagnostics within the file }); From d89abf4934f6ba1e9888d03c1ca7b35b8c83ae49 Mon Sep 17 00:00:00 2001 From: Stephen Carter <123964848+stephen-carter-at-sf@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:29:56 -0400 Subject: [PATCH 17/17] NEW: @W-19037698@: Add in support for 6 more rules to the A4D Quick Fix feature (#275) --- src/lib/agentforce/supported-rules.ts | 30 +++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/lib/agentforce/supported-rules.ts b/src/lib/agentforce/supported-rules.ts index cf5205c3..81cc6128 100644 --- a/src/lib/agentforce/supported-rules.ts +++ b/src/lib/agentforce/supported-rules.ts @@ -65,15 +65,16 @@ export const A4D_SUPPORTED_RULES: Map = new Map([ // ==== Rules from rule selector: 'pmd:ErrorProne:Apex' // ======================================================================= ['AvoidDirectAccessTriggerMap', ViolationContextScope.MethodScope], + ['AvoidStatefulDatabaseResult', ViolationContextScope.ClassScope], ['InaccessibleAuraEnabledGetter', ViolationContextScope.MethodScope], ['MethodWithSameNameAsEnclosingClass', ViolationContextScope.MethodScope], ['OverrideBothEqualsAndHashcode', ViolationContextScope.ViolationScope], ['TestMethodsMustBeInTestClasses', ViolationContextScope.ClassScope], + ['TypeShadowsBuiltInNamespace', ViolationContextScope.ViolationScope], // NOTE: We have decided that the following `ErrorProne` rules either do not get any value from A4D Quick Fix // suggestions or that the model currently gives back poor suggestions: // AvoidHardcodingId, AvoidNonExistentAnnotations, EmptyCatchBlock, EmptyIfStmt, EmptyStatementBlock, // EmptyTryOrFinallyBlock, EmptyWhileStmt - // NOTE: New rules like AvoidStatefulDatabaseResult and TypeShadowsBuiltInNamespace have yet to be evaluated. // ======================================================================= @@ -83,7 +84,7 @@ export const A4D_SUPPORTED_RULES: Map = new Map([ // ======================================================================= - // ==== Rules from rule selector: 'pmd:Security:Apex' + // ==== Rules from rule selector: 'pmd:Security:Apex' (except AppExchange rules) // ======================================================================= ['ApexBadCrypto', ViolationContextScope.MethodScope], ['ApexCRUDViolation', ViolationContextScope.MethodScope], @@ -94,9 +95,30 @@ export const A4D_SUPPORTED_RULES: Map = new Map([ ['ApexSOQLInjection', ViolationContextScope.MethodScope], ['ApexSuggestUsingNamedCred', ViolationContextScope.MethodScope], ['ApexXSSFromEscapeFalse', ViolationContextScope.MethodScope], - ['ApexXSSFromURLParam', ViolationContextScope.ViolationScope] + ['ApexXSSFromURLParam', ViolationContextScope.ViolationScope], // NOTE: We have decided that the following `Security` rule(s) either do not get any value from A4D Quick Fix // suggestions or that the model currently gives back poor suggestions: // ApexOpenRedirect - // NOTE: AppExchange rules have not been evaluated. + + // ======================================================================= + // ==== Rules from rule selector: 'pmd:Performance:Apex' + // ======================================================================= + ['EagerlyLoadedDescribeSObjectResult', ViolationContextScope.ViolationScope], + ['OperationWithHighCostInLoop', ViolationContextScope.MethodScope], + ['OperationWithLimitsInLoop', ViolationContextScope.MethodScope], + // NOTE: We have decided that the following `Performance` rule(s) either do not get any value from A4D Quick Fix + // suggestions or that the model currently gives back poor suggestions: + // AvoidDebugStatements, AvoidNonRestrictiveQueries + + // ======================================================================= + // ==== Rules from rule selector: 'pmd:AppExchange:Apex' + // ======================================================================= + ['AvoidGlobalInstallUninstallHandlers', ViolationContextScope.ClassScope] + // NOTE: We have decided that the following `AppExchange` rule(s) either do not get any value from A4D Quick Fix + // suggestions or that the model currently gives back poor suggestions: + // AvoidChangeProtectionUnprotected, AvoidGetInstanceWithTaint, AvoidHardcodedCredentialsInFieldDecls, + // AvoidHardcodedCredentialsInHttpHeader, AvoidHardcodedCredentialsInSetPassword, + // AvoidHardcodedCredentialsInVarAssign, AvoidHardcodedCredentialsInVarDecls, AvoidInvalidCrudContentDistribution, + // AvoidSecurityEnforcedOldApiVersion, AvoidUnauthorizedApiSessionIdInApex, AvoidUnauthorizedGetSessionIdInApex, + // AvoidUnsafePasswordManagementUse ]);