diff --git a/README.md b/README.md index 3013e9e..6be67cd 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Use our side bar or the **Command Palette** and type `Flow Scanner` to see the list of all available commands. +* `Configure Rules` Allows to define rules and expressions as per defined in the [core documentation](https://github.com/Flow-Scanner/lightning-flow-scanner-core). * `Scan Flows` allows choosing either a directory or a selection of flows to run the analysis against. - *More information on the default rules can be found in the [core documentation](https://github.com/Flow-Scanner/lightning-flow-scanner-core).* * `Fix Flows` will apply available fixes automatically. * `Open Documentation` can be used to reference the documentation. @@ -23,7 +23,6 @@ Use our side bar or the **Command Palette** and type `Flow Scanner` to see the l | `lightningFlowScanner.SpecifyFiles` | Specify flow file paths instead of a root directory. | `false` | | `lightningFlowScanner.NamingConvention` | Specify a REGEX expression to use as Flow Naming convention. | `"[A-Za-z0-9]+_[A-Za-z0-9]+"` | | `lightningFlowScanner.APIVersion` | Specify an expression to validate the API version, i.e. '===50'(use at least 50). | `">50"` | -| `lightningFlowScanner.Reset` | Reset all configurations on every scan | `false` | ## Development Setup diff --git a/package-lock.json b/package-lock.json index cef0de7..d7455da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "lightning-flow-scanner-vsx", - "version": "1.7.2", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lightning-flow-scanner-vsx", - "version": "1.7.2", + "version": "1.8.0", "license": "AGPL-3.0", "dependencies": { "convert-array-to-csv": "^2.0.0", - "lightning-flow-scanner-core": "^5.9.0", + "lightning-flow-scanner-core": "5.9.4", "tabulator-tables": "^6.3.1", "uuid": "^11.0.5", "xml2js": "^0.6.2", @@ -16099,15 +16099,15 @@ "peer": true }, "node_modules/lightning-flow-scanner-core": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/lightning-flow-scanner-core/-/lightning-flow-scanner-core-5.9.0.tgz", - "integrity": "sha512-Y1u4De1rkm38on0e1j0k4tkPEUYpiJpaGwzB73EPly7piHnPb9VpMqWzP7wFgHUvBBYpzf/KdfhNMv6m3BQALg==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/lightning-flow-scanner-core/-/lightning-flow-scanner-core-5.9.4.tgz", + "integrity": "sha512-MO15uAPbR8COiwRynHU+Y0M3SQY5S9BIOCmTB46gZ9vMMpUZUaxOI2OzHIvfXWvRMBBcHqg5XJEZtpdpMNY7Tw==", "license": "MIT", "dependencies": { "xmlbuilder2": "^3.1.1" }, "engines": { - "node": "^20 || ^22 || ^23" + "node": " ^18 || ^20 || ^22 || ^23" } }, "node_modules/lilconfig": { diff --git a/package.json b/package.json index 0e0f028..30ed119 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "icon": "media/lightningflow.png", "description": "A VS Code Extension for analysis and optimization of Salesforce Flows. Scans metadata for 20+ issues such as hardcoded IDs, unsafe contexts, inefficient SOQL/DML operations, recursion risks, and missing fault handling. Supports auto-fixes, rule configurations, and tests integration.", - "version": "1.7.2", + "version": "1.8.0", "engines": { "vscode": "^1.99.1" }, @@ -37,11 +37,6 @@ "type": "string", "default": ">50", "description": "Specify an expression to validate the API version, i.e. '===50'(use at least 50)." - }, - "lightningFlowScanner.Reset": { - "type": "boolean", - "default": false, - "description": "Reset all configurations on every scan" } } }, @@ -180,7 +175,7 @@ }, "dependencies": { "convert-array-to-csv": "^2.0.0", - "lightning-flow-scanner-core": "^5.9.0", + "lightning-flow-scanner-core": "5.9.4", "tabulator-tables": "^6.3.1", "uuid": "^11.0.5", "xml2js": "^0.6.2", @@ -195,6 +190,7 @@ "flow scanner", "salesforce flow", "best practices", - "code quality" + "code quality", + "salesforce automation" ] } diff --git a/src/commands/handlers.ts b/src/commands/handlers.ts index 7051d26..61cf77c 100644 --- a/src/commands/handlers.ts +++ b/src/commands/handlers.ts @@ -9,6 +9,7 @@ import { CacheProvider } from '../providers/cache-provider'; import { testdata } from '../store/testdata'; import { OutputChannel } from '../providers/outputChannel'; import { ConfigProvider } from '../providers/config-provider'; +import * as YAML from 'yaml'; const { USE_NEW_CONFIG: isUseNewConfig } = process.env; @@ -40,83 +41,151 @@ export default class Commands { vscode.env.openExternal(url); } - private async configRules() { - if (isUseNewConfig === 'true') { - await this.ruleConfiguration(); +private async configRules() { + type RuleWithExpression = { severity: string; expression?: string }; + type RuleConfig = Record; + + // If new YAML config flow is enabled + if (isUseNewConfig === 'true') { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage('No workspace folder found.'); return; } - const allRules: core.IRuleDefinition[] = [ - ...core.getRules() - ]; - const ruleConfig = { rules: {} }; - let items = allRules.map((rule) => { - return { label: rule.label, value: rule.name, picked: true }; - }); + const workspacePath = workspaceFolders[0].uri.fsPath; + const configProvider = new ConfigProvider(); + + try { + const configResult = await configProvider.discover(workspacePath); + + // Type assertion for safe access + const configTyped = configResult.config as { rules?: RuleConfig } | undefined; + const rules: RuleConfig = configTyped?.rules ?? {}; - const selectedRules = (await vscode.window.showQuickPick(items, { - canPickMany: true, - })) as { label: string; value: string }[]; + // Prompt for missing expressions + if (!rules.FlowName?.expression) { + const naming = await vscode.window.showInputBox({ + prompt: 'Define a naming convention for Flow Names (REGEX format).', + placeHolder: '[A-Za-z0-9]+_[A-Za-z0-9]+', + value: '[A-Za-z0-9]+_[A-Za-z0-9]+', + }); + if (naming) rules.FlowName = { severity: 'error', expression: naming }; + } - for (const rule of allRules) { - if (selectedRules.map((r) => r.value).includes(rule.name)) { - ruleConfig.rules[rule.name] = { severity: 'error' }; + if (!rules.APIVersion?.expression) { + const apiVersion = await vscode.window.showInputBox({ + prompt: 'Set an API Version rule (e.g. ">50" or ">=60").', + placeHolder: '>50', + value: '>50', + }); + if (apiVersion) rules.APIVersion = { severity: 'error', expression: apiVersion }; } + + // Persist updated YAML config using yaml.stringify + const yamlString = YAML.stringify(configResult.config); + await vscode.workspace.fs.writeFile( + vscode.Uri.file(configResult.fspath), + new TextEncoder().encode(yamlString) + ); + + // Store rules in cache + await CacheProvider.instance.set('ruleconfig', rules); + OutputChannel.getInstance().logChannel.debug('Stored YAML rule config', rules); + + // Open config file in editor + const document = await vscode.workspace.openTextDocument(configResult.fspath); + await vscode.window.showTextDocument(document); + vscode.window.showInformationMessage(`Loaded configuration from ${configResult.fspath}`); + return; + } catch (err: any) { + vscode.window.showErrorMessage(`Error loading configuration: ${err?.message || err}`); + return; + } + } + + // ----- Legacy QuickPick flow ----- + const allRules: core.IRuleDefinition[] = [...core.getRules()]; + const ruleConfig: RuleConfig = {}; + + const items = allRules.map((rule) => ({ + label: rule.label, + value: rule.name, + picked: true, + })); + + const selectedRules = (await vscode.window.showQuickPick(items, { + canPickMany: true, + })) as { label: string; value: string }[]; + + if (!selectedRules) { + vscode.window.showInformationMessage('No rules selected.'); + return; + } + + for (const rule of allRules) { + if (selectedRules.map((r) => r.value).includes(rule.name)) { + ruleConfig[rule.name] = { severity: 'error' }; } - if (selectedRules.map((r) => r.value).includes('FlowName')) { - const namingConventionString = await vscode.window.showInputBox({ - prompt: - 'Readability of a flow is very important. Setting a naming convention for the Flow Name will improve the findability/searchability and overall consistency. You can define your default naming convention using REGEX.', - placeHolder: '[A-Za-z0-9]+_[A-Za-z0-9]+', - value: '[A-Za-z0-9]+_[A-Za-z0-9]+', - }); - ruleConfig.rules['FlowName'] = { - severity: 'error', - expression: namingConventionString, - }; + } + + // Prompt for FlowName expression if selected + if (selectedRules.some((r) => r.value === 'FlowName')) { + const naming = await vscode.window.showInputBox({ + prompt: 'Define a naming convention for Flow Names (REGEX format).', + placeHolder: '[A-Za-z0-9]+_[A-Za-z0-9]+', + value: '[A-Za-z0-9]+_[A-Za-z0-9]+', + }); + if (naming) { + ruleConfig['FlowName'] = { severity: 'error', expression: naming }; await vscode.workspace .getConfiguration() - .update( - 'lightningFlowScanner.NamingConvention', - namingConventionString, - true - ); + .update('lightningFlowScanner.NamingConvention', naming, true); } - if (selectedRules.map((r) => r.value).includes('APIVersion')) { - const apiVersionEvalExpressionString = await vscode.window.showInputBox({ - prompt: - ' The Api Version has been available as an attribute on the Flow since API v50.0 and it is recommended to limit variation and to update them on a regular basis. Set an expression to set a valid range of API versions(Minimum 50).', - placeHolder: '>50', - value: '>50', - }); - ruleConfig.rules['APIVersion'] = { - severity: 'error', - expression: apiVersionEvalExpressionString, - }; + } + + // Prompt for APIVersion expression if selected + if (selectedRules.some((r) => r.value === 'APIVersion')) { + const apiVersion = await vscode.window.showInputBox({ + prompt: 'Set an API Version rule (e.g. ">50" or ">=60").', + placeHolder: '>50', + value: '>50', + }); + if (apiVersion) { + ruleConfig['APIVersion'] = { severity: 'error', expression: apiVersion }; await vscode.workspace .getConfiguration() - .update( - 'lightningFlowScanner.APIVersion', - apiVersionEvalExpressionString, - true - ); + .update('lightningFlowScanner.APIVersion', apiVersion, true); } - await CacheProvider.instance.set('ruleconfig', ruleConfig); - OutputChannel.getInstance().logChannel.debug( - 'Stored rule configurations', - ruleConfig - ); } + // Store legacy rules in cache + await CacheProvider.instance.set('ruleconfig', ruleConfig); + OutputChannel.getInstance().logChannel.debug('Stored legacy rule config', ruleConfig); +} + + private async ruleConfiguration() { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage('No workspace folder found.'); + return; + } + + const workspacePath = workspaceFolders[0].uri.fsPath; const configProvider = new ConfigProvider(); - const config = await configProvider.discover( - vscode.workspace.workspaceFolders?.[0].uri.path - ); - const document = await vscode.workspace.openTextDocument(config.fspath); - await vscode.window.showTextDocument(document); + + try { + const config = await configProvider.discover(workspacePath); + const document = await vscode.workspace.openTextDocument(config.fspath); + await vscode.window.showTextDocument(document); + vscode.window.showInformationMessage(`Loaded configuration from ${config.fspath}`); + } catch (err: any) { + vscode.window.showErrorMessage(`Error loading configuration: ${err?.message || err}`); + } } + private async debugView() { let results = testdata as unknown as core.ScanResult[]; await CacheProvider.instance.set('results', results); diff --git a/src/providers/config-provider.ts b/src/providers/config-provider.ts index be8348a..088bb66 100644 --- a/src/providers/config-provider.ts +++ b/src/providers/config-provider.ts @@ -3,7 +3,7 @@ import { getRules, AdvancedRule, } from 'lightning-flow-scanner-core'; -import * as vsce from 'vscode'; +import * as vscode from 'vscode'; import { Document, parse } from 'yaml'; type Configuration = { @@ -15,8 +15,7 @@ export class ConfigProvider { public async discover(configPath: string): Promise { const configurationName = 'flow-scanner'; - const findInJson = [`.${configPath}.json`, `${configurationName}.json`]; - + const findInJson = [`.${configurationName}.json`, `${configurationName}.json`]; const findInYml = [ `.${configurationName}.yml`, `.${configurationName}.yaml`, @@ -25,84 +24,132 @@ export class ConfigProvider { configurationName, ]; - let configFile = await this.attemptToReadConfig( - configPath, - findInJson, - JSON.parse - ); + // Try reading JSON then YAML + let configFile = + (await this.attemptToReadConfig(configPath, findInJson, JSON.parse)) ?? + (await this.attemptToReadConfig(configPath, findInYml, parse)); - if (!configFile) { - configFile = await this.attemptToReadConfig(configPath, findInYml, parse); - } + const vscodeConfig = vscode.workspace.getConfiguration('lightningFlowScanner'); if (!configFile) { - // if at this point there's still nothing. create a new file - configFile = await this.writeConfigFile(configurationName, configPath); + // No file exists, create a new one with workspace settings applied + configFile = await this.writeConfigFile(configurationName, configPath, vscodeConfig); + } else { + // Merge workspace settings into loaded config + configFile.config = this.mergeWorkspaceSettings(configFile.config, vscodeConfig); + + // Persist updated config back to disk + await this.persistConfig(configFile.fspath, configFile.config); } return configFile; } + /** + * Write a new default YAML configuration + */ private async writeConfigFile( configurationName: string, - configPath: string + configPath: string, + vscodeConfig: vscode.WorkspaceConfiguration ): Promise { - const allRules: Record = [ - ...getRules(), - ].reduce( - (acc, rule: AdvancedRule) => { - acc[rule.name] = { severity: 'error' }; - return acc; - }, - {} as Record - ); + const allRules = this.buildDefaultRules(vscodeConfig); + const config = { rules: allRules }; + + // Apply workspace settings (in case FlowName / APIVersion exist) + const finalConfig = this.mergeWorkspaceSettings(config, vscodeConfig); - const config = { - rules: allRules, - }; + const fspath = `${configPath}/.${configurationName}.yml`; + await this.persistConfig(fspath, finalConfig); - const configFile = { - fspath: `${configPath}/.${configurationName}.yml`, - config, - }; + return { fspath, config: finalConfig }; + } + + /** + * Build default rules map (legacy rule names only) + */ + private buildDefaultRules(vscodeConfig: vscode.WorkspaceConfiguration) { + return [...getRules()].reduce((acc, rule: AdvancedRule) => { + acc[rule.name] = { severity: 'error' }; + + // Workspace expressions applied + if (rule.name === 'FlowName') { + const namingConvention = vscodeConfig.get('NamingConvention'); + if (namingConvention) acc[rule.name].expression = namingConvention; + } + + if (rule.name === 'APIVersion') { + const apiVersionExpr = vscodeConfig.get('APIVersion'); + if (apiVersionExpr) acc[rule.name].expression = apiVersionExpr; + } + + return acc; + }, {} as Record); + } - await vsce.workspace.fs.writeFile( - vsce.Uri.file(configFile.fspath), + /** + * Merge VS Code workspace settings into a loaded config + */ + private mergeWorkspaceSettings(config: any, vscodeConfig: vscode.WorkspaceConfiguration) { + if (!config.rules) config.rules = {}; + + const namingConvention = vscodeConfig.get('NamingConvention'); + const apiVersionExpr = vscodeConfig.get('APIVersion'); + + if (namingConvention) { + config.rules['FlowName'] = { + ...(config.rules['FlowName'] || { severity: 'error' }), + expression: namingConvention, + }; + } + + if (apiVersionExpr) { + config.rules['APIVersion'] = { + ...(config.rules['APIVersion'] || { severity: 'error' }), + expression: apiVersionExpr, + }; + } + + return config; + } + + /** + * Persist a config object to disk as YAML + */ + private async persistConfig(fspath: string, config: any) { + await vscode.workspace.fs.writeFile( + vscode.Uri.file(fspath), new TextEncoder().encode(String(new Document(config))) ); - - return configFile; } + /** + * Attempt to read an existing config file from disk + */ private async attemptToReadConfig( basePath: string, potentialFileNames: string[], parser: Function ): Promise { - let foundConfig: Configuration; - await Promise.all( - potentialFileNames.map(async (fileName) => { - if (foundConfig) return; - const file = vsce.Uri.file(`${basePath}/${fileName}`); - try { - const doesFileExist = await vsce.workspace.fs.stat(file); - if (doesFileExist) { - foundConfig = { fspath: file.fsPath, config: undefined }; - const fileContent = Buffer.from( - await vsce.workspace.fs.readFile(file) - ).toString(); - foundConfig.config = parser(fileContent); - } - } catch (e) { - // File does not exist, ignore - } - }) - ); - return foundConfig; + for (const fileName of potentialFileNames) { + const file = vscode.Uri.file(`${basePath}/${fileName}`); + try { + await vscode.workspace.fs.stat(file); + const fileContent = Buffer.from(await vscode.workspace.fs.readFile(file)).toString(); + const config = parser(fileContent); + return { fspath: file.fsPath, config }; + } catch { + // file does not exist + } + } + return null; } + /** + * Load normalized IRulesConfig + */ public async loadConfig(configPath?: string): Promise { const explorerResults = await this.discover(configPath); - return explorerResults?.config ?? {}; + return (explorerResults?.config as IRulesConfig) ?? {}; } } diff --git a/src/services/message-service.ts b/src/services/message-service.ts index c4dcd40..9d03b77 100644 --- a/src/services/message-service.ts +++ b/src/services/message-service.ts @@ -1,6 +1,7 @@ import { CacheProvider } from '../providers/cache-provider'; import * as vscode from 'vscode'; + export default class MessageService { constructor(private webview: vscode.Webview) {} diff --git a/src/store/RuleAliases.ts b/src/store/RuleAliases.ts new file mode 100644 index 0000000..39207bb --- /dev/null +++ b/src/store/RuleAliases.ts @@ -0,0 +1,17 @@ +export const RuleAliases: Record = { + // Allow for backwards compatibility mappings + // Will be removed once exposed in the core package + ActionCallsInLoop: "ActionCallInLoop", + APIVersion: "InvalidAPIVersion", + AutoLayout: "MissingAutoLayout", + CopyAPIName: "UnclearAPINaming", + CyclomaticComplexity: "ExcessiveCyclomaticComplexity", + DMLStatementInLoop: "DMLInLoop", + DuplicateDMLOperation: "DuplicateDML", + FlowDescription: "MissingFlowDescription", + FlowName: "InvalidNamingConvention", + ProcessBuilder: "ProcessBuilderUsage", + RecursiveAfterUpdate: "RecursiveRecordUpdate", + SOQLQueryInLoop: "SOQLInLoop", + TriggerOrder: "UnspecifiedTriggerOrder" +}; \ No newline at end of file diff --git a/webviews/components/Sidebar.svelte b/webviews/components/Sidebar.svelte index 2f0fad9..a5dda7f 100644 --- a/webviews/components/Sidebar.svelte +++ b/webviews/components/Sidebar.svelte @@ -8,6 +8,12 @@ }); } + function configRules() { + tsvscode.postMessage({ + type: "configRules" + }); + } + function scanFlows() { tsvscode.postMessage({ type: "scanFlows" @@ -31,6 +37,9 @@