From 3e0e0177a3f116bb81edf53dd33df8b51c15acaf Mon Sep 17 00:00:00 2001 From: abarseni Date: Wed, 21 Oct 2026 12:46:00 +0200 Subject: [PATCH 1/4] Add Flow Analyzer engine package Adds the code-analyzer-flowanalyzer-engine package. The engine collects Flow metadata files from the workspace, bundles them into a metadata archive, authenticates and sends them to the Flow Analyzer API, and maps the returned findings back to locations in the flow files. Includes config, auth service, API client, file collector, archive builder, location mapper, plugin/engine wiring, and unit tests. --- .../code-analyzer-flowanalyzer-engine/LICENSE | 14 + .../README.md | 24 ++ .../eslint.config.mjs | 16 ++ .../package.json | 67 +++++ .../src/config.ts | 149 +++++++++++ .../src/constants.ts | 17 ++ .../src/engine.ts | 180 +++++++++++++ .../src/index.ts | 8 + .../src/messages.ts | 106 ++++++++ .../src/plugin.ts | 42 +++ .../src/services/FindingLocationMapper.ts | 71 +++++ .../src/services/FlowAnalyzerApiClient.ts | 187 ++++++++++++++ .../src/services/FlowAnalyzerAuthService.ts | 58 +++++ .../src/services/FlowFileCollector.ts | 112 ++++++++ .../src/services/MetadataArchiveBuilder.ts | 23 ++ .../src/types.ts | 64 +++++ .../test/api-client.test.ts | 243 ++++++++++++++++++ .../test/auth.test.ts | 57 ++++ .../test/engine.test.ts | 222 ++++++++++++++++ .../test/file-collector.test.ts | 154 +++++++++++ .../test/location-mapper.test.ts | 58 +++++ .../test/metadata-archive.test.ts | 13 + .../test/plugin.test.ts | 85 ++++++ .../contains-no-flows/dummy-file.txt | 1 + .../contains-one-flow/Sample.flow-meta.xml | 12 + .../contains-one-flow/notes.txt | 1 + .../test/test-data/sample-flow/notes.txt | 1 + .../test/test-helpers.ts | 37 +++ .../tsconfig.build.json | 17 ++ .../tsconfig.json | 11 + 30 files changed, 2050 insertions(+) create mode 100644 packages/code-analyzer-flowanalyzer-engine/LICENSE create mode 100644 packages/code-analyzer-flowanalyzer-engine/README.md create mode 100644 packages/code-analyzer-flowanalyzer-engine/eslint.config.mjs create mode 100644 packages/code-analyzer-flowanalyzer-engine/package.json create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/config.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/constants.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/engine.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/index.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/messages.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/plugin.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/services/FindingLocationMapper.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/services/FlowFileCollector.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/services/MetadataArchiveBuilder.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/src/types.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/location-mapper.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/metadata-archive.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-no-flows/dummy-file.txt create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/notes.txt create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt create mode 100644 packages/code-analyzer-flowanalyzer-engine/test/test-helpers.ts create mode 100644 packages/code-analyzer-flowanalyzer-engine/tsconfig.build.json create mode 100644 packages/code-analyzer-flowanalyzer-engine/tsconfig.json diff --git a/packages/code-analyzer-flowanalyzer-engine/LICENSE b/packages/code-analyzer-flowanalyzer-engine/LICENSE new file mode 100644 index 00000000..39d18bf7 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/LICENSE @@ -0,0 +1,14 @@ +BSD 3-Clause License + +Copyright (c) 2025, Salesforce.com, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +* Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/code-analyzer-flowanalyzer-engine/README.md b/packages/code-analyzer-flowanalyzer-engine/README.md new file mode 100644 index 00000000..9941ffc9 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/README.md @@ -0,0 +1,24 @@ +# Flow Analyzer Engine + +Salesforce Code Analyzer engine named `flowAnalyzer`. It sends local `.flow` / `.flow-meta.xml` files to an org-backed Connect API and maps findings back onto the original XML. + +Analyze POST: `/services/data/v68.0/flowbuilder/flow-analyzer/analysis/metadata-archive` (`multipart/form-data` ZIP in `fileData`). + +This package does **not** replace the local Python `flow` engine. + +## Configuration + +```yaml +engines: + flowAnalyzer: + target_org: myOrg +``` + +`target_org` is required. The CLI `--target-org` flag should populate it. The engine never falls back to the default Salesforce CLI org. + +## Commands + +``` +sf code-analyzer run --rule-selector flowAnalyzer --target-org myOrg +sf code-analyzer rules --rule-selector flowAnalyzer --target-org myOrg +``` diff --git a/packages/code-analyzer-flowanalyzer-engine/eslint.config.mjs b/packages/code-analyzer-flowanalyzer-engine/eslint.config.mjs new file mode 100644 index 00000000..44661709 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/eslint.config.mjs @@ -0,0 +1,16 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-unused-vars": ["error", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + }] + } + } +); diff --git a/packages/code-analyzer-flowanalyzer-engine/package.json b/packages/code-analyzer-flowanalyzer-engine/package.json new file mode 100644 index 00000000..451fd8b3 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/package.json @@ -0,0 +1,67 @@ +{ + "name": "@salesforce/code-analyzer-flowanalyzer-engine", + "description": "Flow Analyzer Engine Package for the Salesforce Code Analyzer", + "version": "0.1.0-SNAPSHOT", + "author": "The Salesforce Code Analyzer Team", + "license": "BSD-3-Clause", + "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", + "repository": { + "type": "git", + "url": "git+https://github.com/forcedotcom/code-analyzer-core.git", + "directory": "packages/code-analyzer-flowanalyzer-engine" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@salesforce/code-analyzer-engine-api": "0.42.0", + "@salesforce/core": "^8.31.2", + "archiver": "^7.0.1", + "form-data": "^4.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@types/archiver": "^6.0.4", + "@types/jest": "^30.0.0", + "@types/node": "^20.0.0", + "eslint": "^9.39.5", + "jest": "^30.4.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.12", + "typescript": "^5.9.3", + "typescript-eslint": "^8.67.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "files": [ + "dist", + "LICENSE", + "package.json" + ], + "scripts": { + "build": "tsc --build tsconfig.build.json --verbose", + "test": "tsc --build tsconfig.json && jest --coverage", + "lint": "eslint src/**/*.ts", + "package": "npm pack", + "all": "npm run build && npm run lint && npm run test && npm run package", + "clean": "tsc --build tsconfig.build.json --clean", + "postclean": "rimraf dist && rimraf coverage && rimraf ./*.tgz && rimraf vulnerabilities", + "scrub": "npm run clean && rimraf node_modules", + "showcoverage": "open ./coverage/lcov-report/index.html" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/*.test.ts" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/dist/" + ], + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/index.ts" + ] + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/config.ts b/packages/code-analyzer-flowanalyzer-engine/src/config.ts new file mode 100644 index 00000000..cd207bda --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/config.ts @@ -0,0 +1,149 @@ +import { ConfigDescription, ConfigValueExtractor } from '@salesforce/code-analyzer-engine-api'; +import { getMessage } from './messages'; +import { + DEFAULT_ANALYZE_PATH, + DEFAULT_API_TIMEOUT_MS, + DEFAULT_API_VERSION, + DEFAULT_MAX_FILE_BYTES, + DEFAULT_MAX_FILE_COUNT, + DEFAULT_MAX_PAYLOAD_BYTES, + DEFAULT_RULES_PATH +} from './constants'; + +export type FlowAnalyzerEngineConfig = { + target_org: string; + api_timeout_ms: number; + api_version: string; + rules_path: string; + analyze_path: string; + max_file_count: number; + max_file_bytes: number; + max_payload_bytes: number; +}; + +export const DEFAULT_FLOW_ANALYZER_ENGINE_CONFIG: FlowAnalyzerEngineConfig = { + target_org: '', + api_timeout_ms: DEFAULT_API_TIMEOUT_MS, + api_version: DEFAULT_API_VERSION, + rules_path: DEFAULT_RULES_PATH, + analyze_path: DEFAULT_ANALYZE_PATH, + max_file_count: DEFAULT_MAX_FILE_COUNT, + max_file_bytes: DEFAULT_MAX_FILE_BYTES, + max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES +}; + +export const FLOW_ANALYZER_ENGINE_CONFIG_DESCRIPTION: ConfigDescription = { + overview: getMessage('ConfigOverview'), + fieldDescriptions: { + target_org: { + descriptionText: getMessage('ConfigFieldDescription_target_org'), + valueType: 'string', + defaultValue: null + }, + api_timeout_ms: { + descriptionText: getMessage('ConfigFieldDescription_api_timeout_ms'), + valueType: 'number', + defaultValue: DEFAULT_API_TIMEOUT_MS + }, + api_version: { + descriptionText: getMessage('ConfigFieldDescription_api_version'), + valueType: 'string', + defaultValue: DEFAULT_API_VERSION + }, + rules_path: { + descriptionText: getMessage('ConfigFieldDescription_rules_path'), + valueType: 'string', + defaultValue: DEFAULT_RULES_PATH + }, + analyze_path: { + descriptionText: getMessage('ConfigFieldDescription_analyze_path'), + valueType: 'string', + defaultValue: DEFAULT_ANALYZE_PATH + }, + max_file_count: { + descriptionText: getMessage('ConfigFieldDescription_max_file_count'), + valueType: 'number', + defaultValue: DEFAULT_MAX_FILE_COUNT + }, + max_file_bytes: { + descriptionText: getMessage('ConfigFieldDescription_max_file_bytes'), + valueType: 'number', + defaultValue: DEFAULT_MAX_FILE_BYTES + }, + max_payload_bytes: { + descriptionText: getMessage('ConfigFieldDescription_max_payload_bytes'), + valueType: 'number', + defaultValue: DEFAULT_MAX_PAYLOAD_BYTES + } + } +}; + +export async function validateAndNormalizeConfig( + configValueExtractor: ConfigValueExtractor +): Promise { + configValueExtractor.validateContainsOnlySpecifiedKeys([ + 'target_org', + 'api_timeout_ms', + 'api_version', + 'rules_path', + 'analyze_path', + 'max_file_count', + 'max_file_bytes', + 'max_payload_bytes' + ]); + + const targetOrg: string | undefined = configValueExtractor.extractString('target_org'); + if (!targetOrg || targetOrg.trim().length === 0) { + throw new Error(getMessage('MissingTargetOrg', configValueExtractor.getFieldPath('target_org'))); + } + + const apiTimeoutMs: number = configValueExtractor.extractNumber( + 'api_timeout_ms', + DEFAULT_API_TIMEOUT_MS + ) ?? DEFAULT_API_TIMEOUT_MS; + if (apiTimeoutMs <= 0) { + throw new Error(getMessage('ConfigValueMustBePositive', configValueExtractor.getFieldPath('api_timeout_ms'))); + } + + const apiVersion: string = configValueExtractor.extractString('api_version', DEFAULT_API_VERSION) + ?? DEFAULT_API_VERSION; + const rulesPath: string = configValueExtractor.extractString('rules_path', DEFAULT_RULES_PATH) + ?? DEFAULT_RULES_PATH; + const analyzePath: string = configValueExtractor.extractString('analyze_path', DEFAULT_ANALYZE_PATH) + ?? DEFAULT_ANALYZE_PATH; + + const maxFileCount: number = configValueExtractor.extractNumber( + 'max_file_count', + DEFAULT_MAX_FILE_COUNT + ) ?? DEFAULT_MAX_FILE_COUNT; + if (maxFileCount <= 0) { + throw new Error(getMessage('ConfigValueMustBePositive', configValueExtractor.getFieldPath('max_file_count'))); + } + + const maxFileBytes: number = configValueExtractor.extractNumber( + 'max_file_bytes', + DEFAULT_MAX_FILE_BYTES + ) ?? DEFAULT_MAX_FILE_BYTES; + if (maxFileBytes <= 0) { + throw new Error(getMessage('ConfigValueMustBePositive', configValueExtractor.getFieldPath('max_file_bytes'))); + } + + const maxPayloadBytes: number = configValueExtractor.extractNumber( + 'max_payload_bytes', + DEFAULT_MAX_PAYLOAD_BYTES + ) ?? DEFAULT_MAX_PAYLOAD_BYTES; + if (maxPayloadBytes <= 0) { + throw new Error(getMessage('ConfigValueMustBePositive', configValueExtractor.getFieldPath('max_payload_bytes'))); + } + + return { + target_org: targetOrg.trim(), + api_timeout_ms: apiTimeoutMs, + api_version: apiVersion, + rules_path: rulesPath, + analyze_path: analyzePath, + max_file_count: maxFileCount, + max_file_bytes: maxFileBytes, + max_payload_bytes: maxPayloadBytes + }; +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/constants.ts b/packages/code-analyzer-flowanalyzer-engine/src/constants.ts new file mode 100644 index 00000000..8bddea51 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/constants.ts @@ -0,0 +1,17 @@ +export const ENGINE_NAME = 'flowAnalyzer'; + +export const FLOW_FILE_EXTENSIONS = ['.flow', '.flow-meta.xml']; + +export const DEFAULT_API_VERSION = '68.0'; + +export const DEFAULT_RULES_PATH = '/flowbuilder/flow-analyzer/rules'; + +export const DEFAULT_ANALYZE_PATH = '/flowbuilder/flow-analyzer/analysis/metadata-archive'; + +export const DEFAULT_API_TIMEOUT_MS = 300000; + +export const DEFAULT_MAX_FILE_COUNT = 100; + +export const DEFAULT_MAX_FILE_BYTES = 1024 * 1024; + +export const DEFAULT_MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; diff --git a/packages/code-analyzer-flowanalyzer-engine/src/engine.ts b/packages/code-analyzer-flowanalyzer-engine/src/engine.ts new file mode 100644 index 00000000..1d092f06 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/engine.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { + DescribeOptions, + Engine, + EngineRunResults, + LogLevel, + RuleDescription, + RunOptions, + Violation +} from '@salesforce/code-analyzer-engine-api'; +import { ENGINE_NAME } from './constants'; +import { FlowAnalyzerEngineConfig } from './config'; +import { getMessage } from './messages'; +import { CollectedFlowFile, FlowAnalyzerFinding } from './types'; +import { FlowAnalyzerAuthService } from './services/FlowAnalyzerAuthService'; +import { FlowAnalyzerApiClient } from './services/FlowAnalyzerApiClient'; +import { FlowFileCollector } from './services/FlowFileCollector'; +import { FindingLocationMapper } from './services/FindingLocationMapper'; + +export class FlowAnalyzerEngine extends Engine { + static readonly NAME = ENGINE_NAME; + + private readonly config: FlowAnalyzerEngineConfig; + private readonly authService: FlowAnalyzerAuthService; + private readonly apiClient: FlowAnalyzerApiClient; + private readonly fileCollector: FlowFileCollector; + private readonly locationMapper: FindingLocationMapper; + private catalogCache?: RuleDescription[]; + + constructor( + config: FlowAnalyzerEngineConfig, + dependencies?: { + authService?: FlowAnalyzerAuthService; + apiClient?: FlowAnalyzerApiClient; + fileCollector?: FlowFileCollector; + locationMapper?: FindingLocationMapper; + } + ) { + super(); + this.config = config; + this.authService = dependencies?.authService ?? new FlowAnalyzerAuthService(this.emitLogEvent.bind(this)); + this.apiClient = dependencies?.apiClient ?? new FlowAnalyzerApiClient( + this.authService, + config.api_timeout_ms, + config.api_version, + config.rules_path, + config.analyze_path, + this.emitLogEvent.bind(this) + ); + this.fileCollector = dependencies?.fileCollector ?? new FlowFileCollector( + config.max_file_count, + config.max_file_bytes, + config.max_payload_bytes + ); + this.locationMapper = dependencies?.locationMapper ?? new FindingLocationMapper(); + } + + getName(): string { + return FlowAnalyzerEngine.NAME; + } + + async getEngineVersion(): Promise { + const pathToPackageJson: string = path.join(__dirname, '..', 'package.json'); + const packageJson: { version: string } = JSON.parse(await fs.readFile(pathToPackageJson, 'utf-8')); + return packageJson.version; + } + + async describeRules(describeOptions: DescribeOptions): Promise { + this.emitDescribeRulesProgressEvent(0); + if (describeOptions.workspace) { + const collected = await this.fileCollector.collect(describeOptions.workspace); + if (collected.length === 0) { + this.emitLogEvent(LogLevel.Debug, getMessage('NoFlowFiles')); + this.emitDescribeRulesProgressEvent(100); + return []; + } + } + + await this.ensureAuthenticated(); + this.emitDescribeRulesProgressEvent(50); + try { + this.catalogCache = await this.apiClient.describeRules(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + this.emitLogEvent(LogLevel.Error, getMessage('CatalogUnavailable', detail)); + this.emitDescribeRulesProgressEvent(100); + return []; + } + this.emitDescribeRulesProgressEvent(100); + return this.catalogCache; + } + + async runRules(ruleNames: string[], runOptions: RunOptions): Promise { + this.emitRunRulesProgressEvent(0); + if (ruleNames.length === 0) { + this.emitRunRulesProgressEvent(100); + return { violations: [] }; + } + + const collected = await this.fileCollector.collect(runOptions.workspace); + if (collected.length === 0) { + this.emitRunRulesProgressEvent(100); + return { violations: [] }; + } + + try { + await this.ensureAuthenticated(); + this.emitRunRulesProgressEvent(20); + + const catalog = this.catalogCache ?? await this.apiClient.describeRules(); + this.catalogCache = catalog; + const catalogNames = new Set(catalog.map(rule => rule.name)); + const selectedRules = new Set(ruleNames); + + this.emitRunRulesProgressEvent(40); + const findings = await this.apiClient.analyze(collected, { rules: ruleNames }); + this.emitRunRulesProgressEvent(80); + + const submittedPaths = new Map(collected.map(file => [file.relativeFilePath, file])); + const violations: Violation[] = []; + for (const finding of findings) { + this.assertFindingContract(finding, catalogNames, submittedPaths); + const findingRule = finding.ruleId || finding.ruleName; + if (!selectedRules.has(findingRule)) { + continue; + } + const collectedFile = submittedPaths.get(finding.relativeFilePath)!; + const location = this.locationMapper.map(finding, collectedFile); + violations.push({ + ruleName: findingRule, + message: finding.message, + codeLocations: [location], + primaryLocationIndex: 0, + resourceUrls: [] + }); + } + + this.emitRunRulesProgressEvent(100); + return { violations }; + } catch (err) { + return this.skipWithError(err); + } + } + + private skipWithError(error: unknown): EngineRunResults { + const detail = error instanceof Error ? error.message : String(error); + this.emitLogEvent(LogLevel.Error, detail); + this.emitRunRulesProgressEvent(100); + return { + violations: [], + insights: { + status: 'skipped', + error: { + code: 'FLOW_ANALYZER_UNAVAILABLE', + message: detail, + remediation: getMessage('CatalogUnavailableRemediation') + } + } + }; + } + + private async ensureAuthenticated(): Promise { + await this.authService.initialize({ targetOrg: this.config.target_org }); + } + + private assertFindingContract( + finding: FlowAnalyzerFinding, + catalogNames: Set, + submittedPaths: Map + ): void { + const findingRule = finding.ruleId || finding.ruleName; + if (!catalogNames.has(findingRule)) { + throw new Error(getMessage('UnknownRuleInFindings', findingRule)); + } + if (!submittedPaths.has(finding.relativeFilePath)) { + throw new Error(getMessage('PathNotSubmitted', finding.relativeFilePath)); + } + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/index.ts b/packages/code-analyzer-flowanalyzer-engine/src/index.ts new file mode 100644 index 00000000..329c2ac9 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/index.ts @@ -0,0 +1,8 @@ +import { EnginePlugin } from '@salesforce/code-analyzer-engine-api'; +import { FlowAnalyzerEnginePlugin } from './plugin'; + +function createEnginePlugin(): EnginePlugin { + return new FlowAnalyzerEnginePlugin(); +} + +export { createEnginePlugin, FlowAnalyzerEnginePlugin }; diff --git a/packages/code-analyzer-flowanalyzer-engine/src/messages.ts b/packages/code-analyzer-flowanalyzer-engine/src/messages.ts new file mode 100644 index 00000000..64a2b5e3 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/messages.ts @@ -0,0 +1,106 @@ +import { getMessageFromCatalog } from '@salesforce/code-analyzer-engine-api'; + +const MESSAGE_CATALOG: { [key: string]: string } = { + UnsupportedEngineName: + `The FlowAnalyzerEnginePlugin does not support an engine with name '%s'.`, + + ConfigOverview: + `Configuration for the Flow Analyzer engine. Authentication uses the Salesforce CLI session for the required --target-org value.`, + + ConfigFieldDescription_target_org: + `Target Salesforce org alias or username. Required. Typically set by the CLI --target-org flag. Flow Analyzer never falls back to the default org.`, + + ConfigFieldDescription_api_timeout_ms: + `Maximum time to wait for Flow Analyzer API responses, in milliseconds. Default: 300000 (5 minutes).`, + + ConfigFieldDescription_api_version: + `Salesforce REST API version used for Connect calls. Default: 68.0.`, + + ConfigFieldDescription_rules_path: + `Connect path for the rules catalog endpoint. Default: /flowbuilder/flow-analyzer/rules.`, + + ConfigFieldDescription_analyze_path: + `Connect path for the analyze endpoint. Default: /flowbuilder/flow-analyzer/analysis/metadata-archive.`, + + ConfigFieldDescription_max_file_count: + `Maximum number of Flow files that may be submitted in one analyze request.`, + + ConfigFieldDescription_max_file_bytes: + `Maximum size in bytes of a single Flow file.`, + + ConfigFieldDescription_max_payload_bytes: + `Maximum total decoded payload size in bytes for one analyze request.`, + + MissingTargetOrg: + `Flow Analyzer requires an explicit target org. Set '%s' or pass --target-org .`, + + ConfigValueMustBePositive: + `The value of '%s' must be greater than 0.`, + + OrgNotFound: + `We couldn't find the org '%s', or it isn't authenticated.\n\nRun sf org list to see available orgs.\nRun sf org login web --alias %s to authenticate a new org.`, + + AuthNotInitialized: + `Auth service not initialized. Call initialize() first.`, + + AccessTokenUnavailable: + `Access token not available for the selected org.`, + + NoFlowFiles: + `No Flow files have been targeted in the workspace.`, + + CatalogUnavailable: + `Code Analyzer skipped Flow Analyzer because the rule catalog could not be retrieved from the target org: %s`, + + CatalogUnavailableRemediation: + `Confirm the org is on API 266+ with GET /flowbuilder/flow-analyzer/rules, the session is valid, and gate com.salesforce.interaction.enableflowanalyzerapi is enabled.`, + + AnalyzeFailedRemediation: + `Confirm POST /flowbuilder/flow-analyzer/analysis/metadata-archive is available on the target org and retry.`, + + CatalogMalformed: + `Flow Analyzer received a malformed rule catalog from the target org.`, + + AnalyzeFailed: + `Flow Analyzer analyze request failed: %s`, + + AnalyzeMalformed: + `Flow Analyzer received a malformed analyze response from the target org.`, + + UnknownRuleInFindings: + `Flow Analyzer returned finding for unknown rule '%s'. The engine will not silently drop unknown rules.`, + + PathNotSubmitted: + `Flow Analyzer returned finding for path '%s', which was not submitted.`, + + AbsolutePathRejected: + `Flow Analyzer rejected absolute path '%s'. Submit workspace-relative Flow paths only.`, + + TraversalPathRejected: + `Flow Analyzer rejected path '%s' because it contains a parent-directory segment.`, + + SymlinkRejected: + `Flow Analyzer rejected '%s' because symbolic links are not allowed.`, + + DuplicatePathRejected: + `Flow Analyzer rejected duplicate or case-colliding path '%s'.`, + + UnsupportedFileType: + `Flow Analyzer rejected unsupported file '%s'. Only .flow and .flow-meta.xml files are allowed.`, + + ExcessiveFileCount: + `Flow Analyzer rejected the workspace because it contains %d Flow files, which exceeds the limit of %d.`, + + OversizedFile: + `Flow Analyzer rejected '%s' because it is %d bytes, which exceeds the per-file limit of %d.`, + + OversizedPayload: + `Flow Analyzer rejected the request because the total payload is %d bytes, which exceeds the limit of %d.`, + + HttpError: + `Flow Analyzer request to '%s' failed with HTTP %d: %s` +}; + +export function getMessage(msgId: string, ...args: (string | number)[]): string { + return getMessageFromCatalog(MESSAGE_CATALOG, msgId, ...args); +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/plugin.ts b/packages/code-analyzer-flowanalyzer-engine/src/plugin.ts new file mode 100644 index 00000000..b12fd631 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/plugin.ts @@ -0,0 +1,42 @@ +import { + ConfigDescription, + ConfigObject, + ConfigValueExtractor, + Engine, + EnginePluginV1 +} from '@salesforce/code-analyzer-engine-api'; +import { FlowAnalyzerEngine } from './engine'; +import { getMessage } from './messages'; +import { ENGINE_NAME } from './constants'; +import { + FLOW_ANALYZER_ENGINE_CONFIG_DESCRIPTION, + FlowAnalyzerEngineConfig, + validateAndNormalizeConfig +} from './config'; + +export class FlowAnalyzerEnginePlugin extends EnginePluginV1 { + getAvailableEngineNames(): string[] { + return [ENGINE_NAME]; + } + + describeEngineConfig(engineName: string): ConfigDescription { + validateEngineName(engineName); + return FLOW_ANALYZER_ENGINE_CONFIG_DESCRIPTION; + } + + async createEngineConfig(engineName: string, configValueExtractor: ConfigValueExtractor): Promise { + validateEngineName(engineName); + return await validateAndNormalizeConfig(configValueExtractor) as ConfigObject; + } + + async createEngine(engineName: string, engineConfig: ConfigObject): Promise { + validateEngineName(engineName); + return new FlowAnalyzerEngine(engineConfig as FlowAnalyzerEngineConfig); + } +} + +function validateEngineName(engineName: string): void { + if (engineName !== ENGINE_NAME) { + throw new Error(getMessage('UnsupportedEngineName', engineName)); + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FindingLocationMapper.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FindingLocationMapper.ts new file mode 100644 index 00000000..2247e2eb --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FindingLocationMapper.ts @@ -0,0 +1,71 @@ +import { CodeLocation } from '@salesforce/code-analyzer-engine-api'; +import { CollectedFlowFile, FlowAnalyzerFinding } from '../types'; + +export type MappedLocation = CodeLocation & { + usedFileLevelAnchor: boolean; +}; + +export class FindingLocationMapper { + map(finding: FlowAnalyzerFinding, collectedFile: CollectedFlowFile): MappedLocation { + const match = this.findElement(collectedFile.content, finding.elementApiName, finding.affectedField); + if (!match) { + return { + file: collectedFile.absolutePath, + startLine: 1, + startColumn: 1, + comment: this.fileLevelComment(finding), + usedFileLevelAnchor: true + }; + } + return { + file: collectedFile.absolutePath, + startLine: match.line, + startColumn: match.column, + comment: finding.elementType ? `${finding.elementType} ${finding.elementApiName}` : finding.elementApiName, + usedFileLevelAnchor: false + }; + } + + private findElement(xml: string, elementApiName: string, affectedField?: string): { line: number; column: number } | undefined { + if (!elementApiName) { + return undefined; + } + const searchTerm = affectedField + ? `<${affectedField}>${this.escapeRegExp(elementApiName)}` + : `>${this.escapeRegExp(elementApiName)}<`; + const index = this.indexOfInsensitive(xml, searchTerm) ?? this.indexOfInsensitive(xml, elementApiName); + if (index === undefined || index < 0) { + return undefined; + } + return this.offsetToLineColumn(xml, index); + } + + private indexOfInsensitive(haystack: string, needle: string): number | undefined { + const index = haystack.toLowerCase().indexOf(needle.toLowerCase()); + return index >= 0 ? index : undefined; + } + + private offsetToLineColumn(text: string, offset: number): { line: number; column: number } { + const prefix = text.slice(0, offset); + const lines = prefix.split(/\r?\n/); + return { + line: lines.length, + column: lines[lines.length - 1].length + 1 + }; + } + + private fileLevelComment(finding: FlowAnalyzerFinding): string { + const parts = [`elementApiName=${finding.elementApiName}`]; + if (finding.elementType) { + parts.push(`elementType=${finding.elementType}`); + } + if (finding.affectedField) { + parts.push(`affectedField=${finding.affectedField}`); + } + return `File-level 1:1 anchor; exact XML location unavailable (${parts.join(', ')})`; + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts new file mode 100644 index 00000000..0f121065 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts @@ -0,0 +1,187 @@ +import { LogLevel, RuleDescription, SeverityLevel } from '@salesforce/code-analyzer-engine-api'; +import FormData from 'form-data'; +import { getMessage } from '../messages'; +import { + CollectedFlowFile, + FlowAnalyzerAnalyzeResponse, + FlowAnalyzerCatalogRule, + FlowAnalyzerFinding, + FlowAnalyzerRuleSelection, + FlowAnalyzerRulesResponse +} from '../types'; +import { FlowAnalyzerAuthService } from './FlowAnalyzerAuthService'; +import { createMetadataArchive } from './MetadataArchiveBuilder'; + +export class FlowAnalyzerApiClient { + private readonly authService: FlowAnalyzerAuthService; + private readonly timeoutMs: number; + private readonly apiVersion: string; + private readonly rulesPath: string; + private readonly analyzePath: string; + private readonly emitLogEvent: (logLevel: LogLevel, message: string) => void; + private readonly fetchImpl: typeof fetch; + + constructor( + authService: FlowAnalyzerAuthService, + timeoutMs: number, + apiVersion: string, + rulesPath: string, + analyzePath: string, + emitLogEvent: (logLevel: LogLevel, message: string) => void = () => {}, + fetchImpl: typeof fetch = fetch + ) { + this.authService = authService; + this.timeoutMs = timeoutMs; + this.apiVersion = apiVersion; + this.rulesPath = rulesPath; + this.analyzePath = analyzePath; + this.emitLogEvent = emitLogEvent; + this.fetchImpl = fetchImpl; + } + + async describeRules(): Promise { + const payload = await this.requestJson('GET', this.rulesPath); + const rules = Array.isArray(payload) ? payload : payload.rules; + if (!rules) { + throw new Error(getMessage('CatalogMalformed')); + } + return rules.map(rule => this.toRuleDescription(rule)); + } + + async analyze(files: CollectedFlowFile[], ruleSelection?: FlowAnalyzerRuleSelection): Promise { + const zipBuffer = await createMetadataArchive(files); + const form = new FormData(); + form.append('fileData', zipBuffer, { filename: 'flows.zip', contentType: 'application/zip' }); + if (ruleSelection) { + form.append('ruleSelection', JSON.stringify(ruleSelection), { contentType: 'application/json' }); + } + + const payload = await this.requestJson('POST', this.analyzePath, form); + return this.extractFindings(payload); + } + + private extractFindings(payload: FlowAnalyzerAnalyzeResponse): FlowAnalyzerFinding[] { + if (payload.flowAnalyses && Array.isArray(payload.flowAnalyses)) { + return payload.flowAnalyses.flatMap(analysis => + (analysis.findings ?? []).map(finding => ({ + ...finding, + relativeFilePath: finding.relativeFilePath || analysis.relativeFilePath, + ruleName: finding.ruleName || finding.ruleId || '' + })) + ); + } + if (Array.isArray(payload.findings)) { + return payload.findings; + } + throw new Error(getMessage('AnalyzeMalformed')); + } + + private toRuleDescription(rule: FlowAnalyzerCatalogRule): RuleDescription { + const name = rule.id || rule.ruleId || rule.ruleName || rule.name; + if (!name) { + throw new Error(getMessage('CatalogMalformed')); + } + const categoryId = typeof rule.category === 'string' ? rule.category : rule.category?.id; + const tags: string[] = []; + if (rule.enabled !== false) { + tags.push('Recommended'); + } + if (categoryId) { + tags.push(categoryId); + } + if (rule.tags) { + for (const tag of rule.tags) { + if (!tags.includes(tag)) { + tags.push(tag); + } + } + } + const localizedName = rule.name && rule.name !== name ? rule.name : undefined; + return { + name, + severityLevel: this.toSeverityLevel( + rule.effectiveSeverity ?? rule.severityLevel ?? rule.severity ?? rule.defaultSeverity + ), + tags: tags.length > 0 ? tags : ['Recommended'], + description: rule.description || localizedName || '', + resourceUrls: rule.resourceUrls ?? [] + }; + } + + private toSeverityLevel(value: SeverityLevel | string | number | undefined): SeverityLevel { + if (typeof value === 'number' && value >= 1 && value <= 5) { + return value as SeverityLevel; + } + if (typeof value === 'string') { + const upper = value.toUpperCase(); + if (upper === 'ERROR' || upper === 'CRITICAL' || upper === 'HIGH') { + return upper === 'ERROR' || upper === 'CRITICAL' ? SeverityLevel.High : SeverityLevel.High; + } + if (upper === 'WARNING' || upper === 'MODERATE') { + return SeverityLevel.Moderate; + } + if (upper === 'INFO' || upper === 'LOW') { + return upper === 'INFO' ? SeverityLevel.Info : SeverityLevel.Low; + } + const normalized = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + const mapped = SeverityLevel[normalized as keyof typeof SeverityLevel]; + if (typeof mapped === 'number') { + return mapped; + } + } + return SeverityLevel.Moderate; + } + + private async requestJson(method: 'GET' | 'POST', resourcePath: string, body?: unknown): Promise { + const version = this.authService.getApiVersion() || this.apiVersion; + const url = `${this.authService.getInstanceUrl()}/services/data/v${version}${resourcePath}`; + this.emitLogEvent(LogLevel.Fine, `Flow Analyzer ${method} ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const headers: Record = { + Accept: 'application/json' + }; + let requestBody: Buffer | string | undefined; + if (body instanceof FormData) { + Object.assign(headers, body.getHeaders()); + requestBody = body.getBuffer(); + } else if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + requestBody = JSON.stringify(body); + } + + const send = (): Promise => this.fetchImpl(url, { + method, + headers: { ...headers, Authorization: `Bearer ${this.authService.getAccessToken()}` }, + body: requestBody, + signal: controller.signal + }); + + try { + let response = await send(); + if (response.status === 401) { + // Cached session token expired: refresh it once and retry. + await this.authService.refreshAccessToken(); + response = await send(); + } + const text = await response.text(); + if (!response.ok) { + throw new Error(getMessage('HttpError', url, response.status, text)); + } + try { + return JSON.parse(text) as T; + } catch (_err) { + throw new Error(method === 'GET' ? getMessage('CatalogMalformed') : getMessage('AnalyzeMalformed')); + } + } catch (err) { + if (err instanceof Error && (err.message.startsWith('Flow Analyzer') || err.message.includes('malformed'))) { + throw err; + } + const detail = err instanceof Error ? err.message : String(err); + throw new Error(method === 'GET' ? getMessage('CatalogUnavailable', detail) : getMessage('AnalyzeFailed', detail)); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts new file mode 100644 index 00000000..6c805561 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts @@ -0,0 +1,58 @@ +import { Connection, Org } from '@salesforce/core'; +import { LogLevel } from '@salesforce/code-analyzer-engine-api'; +import { getMessage } from '../messages'; +import { AuthConfig } from '../types'; + +export class FlowAnalyzerAuthService { + private connection?: Connection; + private readonly emitLogEvent: (logLevel: LogLevel, message: string) => void; + + constructor(emitLogEvent: (logLevel: LogLevel, message: string) => void = () => {}) { + this.emitLogEvent = emitLogEvent; + } + + async initialize(config: AuthConfig): Promise { + this.emitLogEvent(LogLevel.Fine, `Authenticating with org: ${config.targetOrg}`); + try { + const org = await Org.create({ aliasOrUsername: config.targetOrg }); + this.connection = org.getConnection(); + this.emitLogEvent(LogLevel.Fine, 'Successfully authenticated to org'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + this.emitLogEvent(LogLevel.Fine, `Failed to authenticate with org '${config.targetOrg}': ${errorMessage}`); + throw new Error(getMessage('OrgNotFound', config.targetOrg, config.targetOrg)); + } + } + + getConnection(): Connection { + if (!this.connection) { + throw new Error(getMessage('AuthNotInitialized')); + } + return this.connection; + } + + getAccessToken(): string { + const token = this.getConnection().accessToken; + if (!token) { + throw new Error(getMessage('AccessTokenUnavailable')); + } + return token; + } + + /** + * The cached access token may be expired even though the org is still authorized (the refresh token is valid). + * Since requests are made with raw fetch, jsforce's auto-refresh never kicks in, so refresh explicitly. + */ + async refreshAccessToken(): Promise { + this.emitLogEvent(LogLevel.Fine, 'Refreshing org access token'); + await this.getConnection().refreshAuth(); + } + + getInstanceUrl(): string { + return this.getConnection().instanceUrl; + } + + getApiVersion(): string { + return this.getConnection().version || '64.0'; + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowFileCollector.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowFileCollector.ts new file mode 100644 index 00000000..c1b54f6c --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowFileCollector.ts @@ -0,0 +1,112 @@ +import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { Workspace } from '@salesforce/code-analyzer-engine-api'; +import { getMessage } from '../messages'; +import { FLOW_FILE_EXTENSIONS } from '../constants'; +import { CollectedFlowFile } from '../types'; + +export class FlowFileCollector { + constructor( + private readonly maxFileCount: number, + private readonly maxFileBytes: number, + private readonly maxPayloadBytes: number, + private readonly fsAdapter: { + lstatSync: (filePath: string) => { isSymbolicLink: () => boolean }; + readFileSync: (filePath: string, encoding: BufferEncoding) => string; + realpathSync: (filePath: string) => string; + } = { lstatSync, readFileSync, realpathSync } + ) {} + + async collect(workspace: Workspace): Promise { + const workspaceRoot = workspace.getWorkspaceRoot(); + const targetedFiles = await workspace.getTargetedFiles(); + const flowFiles = targetedFiles.filter(file => this.isFlowFile(file)); + if (flowFiles.length === 0) { + return []; + } + if (flowFiles.length > this.maxFileCount) { + throw new Error(getMessage('ExcessiveFileCount', flowFiles.length, this.maxFileCount)); + } + + const collected: CollectedFlowFile[] = []; + const seenNormalized = new Set(); + let totalBytes = 0; + + for (const absolutePath of flowFiles) { + this.rejectIfUnsafeAbsolutePath(absolutePath); + const relativeFilePath = this.toRelativePath(absolutePath, workspaceRoot); + this.validateRelativePath(relativeFilePath); + this.rejectIfSymlink(absolutePath); + + const normalized = relativeFilePath.toLowerCase(); + if (seenNormalized.has(normalized)) { + throw new Error(getMessage('DuplicatePathRejected', relativeFilePath)); + } + seenNormalized.add(normalized); + + const content = this.fsAdapter.readFileSync(absolutePath, 'utf-8'); + const byteLength = Buffer.byteLength(content, 'utf-8'); + if (byteLength > this.maxFileBytes) { + throw new Error(getMessage('OversizedFile', relativeFilePath, byteLength, this.maxFileBytes)); + } + totalBytes += byteLength; + if (totalBytes > this.maxPayloadBytes) { + throw new Error(getMessage('OversizedPayload', totalBytes, this.maxPayloadBytes)); + } + + collected.push({ + absolutePath, + relativeFilePath, + content + }); + } + + return collected; + } + + private isFlowFile(fileName: string): boolean { + const lower = fileName.toLowerCase(); + return FLOW_FILE_EXTENSIONS.some(ext => lower.endsWith(ext)); + } + + private toRelativePath(absolutePath: string, workspaceRoot: string | null): string { + if (!workspaceRoot) { + throw new Error(getMessage('AbsolutePathRejected', absolutePath)); + } + const relative = path.relative(workspaceRoot, absolutePath).split(path.sep).join('/'); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(getMessage('AbsolutePathRejected', absolutePath)); + } + return relative; + } + + private validateRelativePath(relativeFilePath: string): void { + if (path.isAbsolute(relativeFilePath) || relativeFilePath.startsWith('/')) { + throw new Error(getMessage('AbsolutePathRejected', relativeFilePath)); + } + const segments = relativeFilePath.split('/'); + if (segments.some(segment => segment === '..')) { + throw new Error(getMessage('TraversalPathRejected', relativeFilePath)); + } + if (!this.isFlowFile(relativeFilePath)) { + throw new Error(getMessage('UnsupportedFileType', relativeFilePath)); + } + } + + private rejectIfUnsafeAbsolutePath(absolutePath: string): void { + if (!path.isAbsolute(absolutePath)) { + throw new Error(getMessage('AbsolutePathRejected', absolutePath)); + } + } + + private rejectIfSymlink(absolutePath: string): void { + const stats = this.fsAdapter.lstatSync(absolutePath); + if (stats.isSymbolicLink()) { + throw new Error(getMessage('SymlinkRejected', absolutePath)); + } + const realPath = this.fsAdapter.realpathSync(absolutePath); + if (path.resolve(absolutePath) !== realPath) { + throw new Error(getMessage('SymlinkRejected', absolutePath)); + } + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/MetadataArchiveBuilder.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/MetadataArchiveBuilder.ts new file mode 100644 index 00000000..d3c56eb5 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/MetadataArchiveBuilder.ts @@ -0,0 +1,23 @@ +import { PassThrough } from 'node:stream'; +import archiver from 'archiver'; +import { CollectedFlowFile } from '../types'; + +export async function createMetadataArchive(files: CollectedFlowFile[]): Promise { + const archive = archiver('zip', { zlib: { level: 9 } }); + const chunks: Buffer[] = []; + const output = new PassThrough(); + output.on('data', (chunk: Buffer) => chunks.push(chunk)); + + const completed = new Promise((resolve, reject) => { + output.on('finish', () => resolve(Buffer.concat(chunks))); + archive.on('error', reject); + output.on('error', reject); + }); + + archive.pipe(output); + for (const file of files) { + archive.append(file.content, { name: file.relativeFilePath }); + } + await archive.finalize(); + return completed; +} diff --git a/packages/code-analyzer-flowanalyzer-engine/src/types.ts b/packages/code-analyzer-flowanalyzer-engine/src/types.ts new file mode 100644 index 00000000..39758a7c --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/src/types.ts @@ -0,0 +1,64 @@ +import { SeverityLevel } from '@salesforce/code-analyzer-engine-api'; + +export type AuthConfig = { + targetOrg: string; +}; + +export type FlowAnalyzerRuleSelection = { + rules?: string[]; + categories?: string[]; +}; + +export type FlowAnalyzerCatalogCategory = { + id?: string; + label?: string; +}; + +export type FlowAnalyzerCatalogRule = { + id?: string; + name?: string; + ruleId?: string; + ruleName?: string; + severityLevel?: SeverityLevel | string | number; + severity?: string; + defaultSeverity?: string; + effectiveSeverity?: string; + enabled?: boolean; + tags?: string[]; + category?: string | FlowAnalyzerCatalogCategory; + description?: string; + resourceUrls?: string[]; +}; + +export type FlowAnalyzerRulesResponse = { + rules?: FlowAnalyzerCatalogRule[]; +} | FlowAnalyzerCatalogRule[]; + +export type FlowAnalyzerFinding = { + ruleId?: string; + ruleName: string; + message: string; + relativeFilePath: string; + elementApiName: string; + elementType?: string; + affectedField?: string; + severity?: string; + category?: string; + remediation?: string; +}; + +export type FlowAnalyzerFlowAnalysis = { + relativeFilePath: string; + findings: FlowAnalyzerFinding[]; +}; + +export type FlowAnalyzerAnalyzeResponse = { + flowAnalyses?: FlowAnalyzerFlowAnalysis[]; + findings?: FlowAnalyzerFinding[]; +}; + +export type CollectedFlowFile = { + absolutePath: string; + relativeFilePath: string; + content: string; +}; diff --git a/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts new file mode 100644 index 00000000..eb3af421 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts @@ -0,0 +1,243 @@ +import { SeverityLevel } from '@salesforce/code-analyzer-engine-api'; +import { FlowAnalyzerApiClient } from '../src/services/FlowAnalyzerApiClient'; +import { FlowAnalyzerAuthService } from '../src/services/FlowAnalyzerAuthService'; +import { getMessage } from '../src/messages'; +import { CollectedFlowFile } from '../src/types'; + +describe('FlowAnalyzerApiClient', () => { + let token = 'token'; + const refreshAccessToken = jest.fn(async () => { token = 'fresh-token'; }); + const authService = { + getAccessToken: () => token, + refreshAccessToken, + getInstanceUrl: () => 'https://example.my.salesforce.com', + getApiVersion: () => '68.0' + } as unknown as FlowAnalyzerAuthService; + + const sampleFiles: CollectedFlowFile[] = [{ + absolutePath: '/tmp/a.flow-meta.xml', + relativeFilePath: 'a.flow-meta.xml', + content: '' + }]; + + function createClient(fetchImpl: typeof fetch): FlowAnalyzerApiClient { + return new FlowAnalyzerApiClient( + authService, + 1000, + '68.0', + '/flowbuilder/flow-analyzer/rules', + '/flowbuilder/flow-analyzer/analysis/metadata-archive', + jest.fn(), + fetchImpl + ); + } + + it('maps the listFlowAnalyzerRules catalog contract', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + rules: [{ + id: 'MISSING_FLOW_DESCRIPTION', + name: 'Localized rule', + category: { id: 'DOCUMENTATION_AND_NAMING_STANDARDS', label: 'Docs' }, + defaultSeverity: 'INFO', + effectiveSeverity: 'ERROR', + enabled: true + }] + }) + }); + const rules = await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(rules[0]).toEqual({ + name: 'MISSING_FLOW_DESCRIPTION', + severityLevel: SeverityLevel.High, + tags: ['Recommended', 'DOCUMENTATION_AND_NAMING_STANDARDS'], + description: 'Localized rule', + resourceUrls: [] + }); + expect(fetchImpl.mock.calls[0][0]).toContain('/flowbuilder/flow-analyzer/rules'); + }); + + it('maps a top-level catalog array and numeric severity', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ name: 'R2', severityLevel: 4 }]) + }); + const rules = await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(rules[0].severityLevel).toEqual(SeverityLevel.Low); + expect(rules[0].tags).toEqual(['Recommended']); + }); + + it('maps ERROR/WARNING/INFO severities from the analyzer contract', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify([ + { ruleId: 'R-ERR', severity: 'ERROR' }, + { ruleId: 'R-WARN', severity: 'WARNING' }, + { ruleId: 'R-INFO', severity: 'INFO', category: 'Performance' } + ]) + }); + const rules = await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(rules[0].name).toEqual('R-ERR'); + expect(rules[0].severityLevel).toEqual(SeverityLevel.High); + expect(rules[1].severityLevel).toEqual(SeverityLevel.Moderate); + expect(rules[2].severityLevel).toEqual(SeverityLevel.Info); + expect(rules[2].tags).toEqual(['Recommended', 'Performance']); + }); + + it('fails on a malformed catalog', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ unexpected: true }) + }); + await expect(createClient(fetchImpl as unknown as typeof fetch).describeRules()) + .rejects.toThrow(getMessage('CatalogMalformed')); + }); + + it('posts a multipart ZIP to the metadata-archive endpoint', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + flowAnalyses: [{ + relativeFilePath: 'a.flow-meta.xml', + findings: [{ + ruleId: 'R1', + ruleName: 'R1', + message: 'm', + elementApiName: 'n' + }] + }] + }) + }); + const findings = await createClient(fetchImpl as unknown as typeof fetch).analyze(sampleFiles); + expect(findings).toHaveLength(1); + expect(findings[0].relativeFilePath).toEqual('a.flow-meta.xml'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, options] = fetchImpl.mock.calls[0]; + expect(url).toContain('/flowbuilder/flow-analyzer/analysis/metadata-archive'); + expect(options.method).toEqual('POST'); + expect(Buffer.isBuffer(options.body)).toBe(true); + expect(options.headers['content-type']).toMatch(/multipart\/form-data/); + }); + + it('includes ruleSelection.rules when provided and omits categories', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ flowAnalyses: [] }) + }); + await createClient(fetchImpl as unknown as typeof fetch).analyze(sampleFiles, { rules: ['UnusedVariable'] }); + const body = fetchImpl.mock.calls[0][1].body as Buffer; + expect(body.toString('utf8')).toContain('ruleSelection'); + expect(body.toString('utf8')).toContain('UnusedVariable'); + expect(body.toString('utf8')).not.toContain('categories'); + }); + + beforeEach(() => { + token = 'token'; + refreshAccessToken.mockClear(); + }); + + it('refreshes the access token and retries once on a 401', async () => { + const fetchImpl = jest.fn() + .mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'expired' }) + .mockResolvedValueOnce({ ok: true, status: 200, text: async () => JSON.stringify({ rules: [{ name: 'A' }] }) }); + const rules = await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(rules).toHaveLength(1); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer token'); + expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer fresh-token'); + }); + + it('surfaces HTTP failures', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'nope' + }); + await expect(createClient(fetchImpl as unknown as typeof fetch).describeRules()) + .rejects.toThrow('HTTP 401'); + }); + + it('wraps network failures from analyze as AnalyzeFailed', async () => { + const fetchImpl = jest.fn().mockRejectedValue(new Error('socket hang up')); + await expect(createClient(fetchImpl as unknown as typeof fetch).analyze(sampleFiles)) + .rejects.toThrow(getMessage('AnalyzeFailed', 'socket hang up')); + }); + + it('fails when analyze JSON is not an object with findings', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ nope: true }) + }); + await expect(createClient(fetchImpl as unknown as typeof fetch).analyze(sampleFiles)) + .rejects.toThrow(getMessage('AnalyzeMalformed')); + }); + + it('fails when the catalog JSON is not parseable', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'not-json' + }); + await expect(createClient(fetchImpl as unknown as typeof fetch).describeRules()) + .rejects.toThrow(getMessage('CatalogMalformed')); + }); + + it('fails when a catalog rule is missing a name', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ severityLevel: 2 }]) + }); + await expect(createClient(fetchImpl as unknown as typeof fetch).describeRules()) + .rejects.toThrow(getMessage('CatalogMalformed')); + }); + + it('uses default fetch when no implementation is injected', () => { + const client = new FlowAnalyzerApiClient( + authService, + 1000, + '68.0', + '/flowbuilder/flow-analyzer/rules', + '/flowbuilder/flow-analyzer/analysis/metadata-archive' + ); + expect(client).toBeInstanceOf(FlowAnalyzerApiClient); + }); + + it('keeps disabled catalog rules and omits Recommended', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + rules: [{ + id: 'DISABLED_RULE', + name: 'Off', + effectiveSeverity: 'WARNING', + enabled: false, + category: { id: 'SECURITY' } + }] + }) + }); + const rules = await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(rules[0].name).toEqual('DISABLED_RULE'); + expect(rules[0].tags).toEqual(['SECURITY']); + expect(rules[0].severityLevel).toEqual(SeverityLevel.Moderate); + }); + + it('defaults unknown severity strings to Moderate', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ name: 'R3', severityLevel: 'nope' }]) + }); + const rules = await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(rules[0].severityLevel).toEqual(SeverityLevel.Moderate); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts new file mode 100644 index 00000000..3c7c8f1f --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts @@ -0,0 +1,57 @@ +import { Org } from '@salesforce/core'; +import { FlowAnalyzerAuthService } from '../src/services/FlowAnalyzerAuthService'; +import { getMessage } from '../src/messages'; + +jest.mock('@salesforce/core'); + +describe('FlowAnalyzerAuthService', () => { + const mockConnection = { + instanceUrl: 'https://test.salesforce.com', + accessToken: 'mock_access_token', + version: '64.0' + }; + let authService: FlowAnalyzerAuthService; + + beforeEach(() => { + jest.clearAllMocks(); + authService = new FlowAnalyzerAuthService(jest.fn()); + }); + + it('initializes with an explicit target org', async () => { + const mockOrg = { getConnection: jest.fn().mockReturnValue(mockConnection) }; + (Org.create as jest.Mock).mockResolvedValue(mockOrg); + + await authService.initialize({ targetOrg: 'myorg' }); + + expect(Org.create).toHaveBeenCalledWith({ aliasOrUsername: 'myorg' }); + expect(authService.getInstanceUrl()).toEqual('https://test.salesforce.com'); + expect(authService.getAccessToken()).toEqual('mock_access_token'); + expect(authService.getApiVersion()).toEqual('64.0'); + }); + + it('does not fall back to the default org', async () => { + (Org.create as jest.Mock).mockRejectedValue(new Error('Org not found')); + await expect(authService.initialize({ targetOrg: 'missing' })) + .rejects.toThrow(getMessage('OrgNotFound', 'missing', 'missing')); + expect(Org.create).toHaveBeenCalledWith({ aliasOrUsername: 'missing' }); + expect(Org.create).not.toHaveBeenCalledWith({}); + }); + + it('throws when getters are used before initialize', () => { + expect(() => authService.getConnection()).toThrow(getMessage('AuthNotInitialized')); + }); + + it('throws when the access token is missing', async () => { + const mockOrg = { getConnection: jest.fn().mockReturnValue({ ...mockConnection, accessToken: undefined }) }; + (Org.create as jest.Mock).mockResolvedValue(mockOrg); + await authService.initialize({ targetOrg: 'myorg' }); + expect(() => authService.getAccessToken()).toThrow(getMessage('AccessTokenUnavailable')); + }); + + it('defaults api version when the connection has none', async () => { + const mockOrg = { getConnection: jest.fn().mockReturnValue({ ...mockConnection, version: undefined }) }; + (Org.create as jest.Mock).mockResolvedValue(mockOrg); + await authService.initialize({ targetOrg: 'myorg' }); + expect(authService.getApiVersion()).toEqual('64.0'); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts new file mode 100644 index 00000000..8afdd0a2 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts @@ -0,0 +1,222 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { RuleDescription, SeverityLevel, Workspace } from '@salesforce/code-analyzer-engine-api'; +import { FlowAnalyzerEngine } from '../src/engine'; +import { FlowAnalyzerEngineConfig } from '../src/config'; +import { FlowAnalyzerAuthService } from '../src/services/FlowAnalyzerAuthService'; +import { FlowAnalyzerApiClient } from '../src/services/FlowAnalyzerApiClient'; +import { FlowFileCollector } from '../src/services/FlowFileCollector'; +import { FindingLocationMapper } from '../src/services/FindingLocationMapper'; +import { changeWorkingDirectoryToPackageRoot, createDescribeOptions, createRunOptions } from './test-helpers'; +import { getMessage } from '../src/messages'; + +changeWorkingDirectoryToPackageRoot(); + +const TEST_DATA_FOLDER: string = path.join(__dirname, 'test-data'); + +function createConfig(overrides: Partial = {}): FlowAnalyzerEngineConfig { + return { + target_org: 'my-org', + api_timeout_ms: 1000, + api_version: '68.0', + rules_path: '/flowbuilder/flow-analyzer/rules', + analyze_path: '/flowbuilder/flow-analyzer/analysis/metadata-archive', + max_file_count: 10, + max_file_bytes: 10000, + max_payload_bytes: 20000, + ...overrides + }; +} + +const SAMPLE_RULES: RuleDescription[] = [{ + name: 'UnusedVariable', + severityLevel: SeverityLevel.Moderate, + tags: ['Recommended'], + description: 'Unused flow variable', + resourceUrls: [] +}]; + +describe('FlowAnalyzerEngine', () => { + let authService: jest.Mocked; + let apiClient: jest.Mocked; + + beforeEach(() => { + authService = { + initialize: jest.fn().mockResolvedValue(undefined) + } as unknown as jest.Mocked; + apiClient = { + describeRules: jest.fn().mockResolvedValue(SAMPLE_RULES), + analyze: jest.fn().mockResolvedValue([]) + } as unknown as jest.Mocked; + }); + + function createEngine(): FlowAnalyzerEngine { + return new FlowAnalyzerEngine(createConfig(), { + authService, + apiClient, + fileCollector: new FlowFileCollector(10, 10000, 20000), + locationMapper: new FindingLocationMapper() + }); + } + + it('returns flowAnalyzer from getName', () => { + expect(createEngine().getName()).toEqual('flowAnalyzer'); + }); + + it('returns a semantic version', async () => { + const version = await createEngine().getEngineVersion(); + expect(version).toMatch(/\d+\.\d+\.\d+.*/); + }); + + it('returns no rules when the workspace has no Flow files', async () => { + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-no-flows')]); + const rules = await createEngine().describeRules(createDescribeOptions(workspace)); + expect(rules).toEqual([]); + expect(apiClient.describeRules).not.toHaveBeenCalled(); + }); + + it('maps the remote catalog when Flow files exist', async () => { + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const rules = await createEngine().describeRules(createDescribeOptions(workspace)); + expect(rules).toEqual(SAMPLE_RULES); + expect(authService.initialize).toHaveBeenCalledWith({ targetOrg: 'my-org' }); + }); + + it('returns no rules instead of throwing when the catalog cannot be retrieved', async () => { + apiClient.describeRules.mockRejectedValue(new Error('HTTP 404')); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const engine = createEngine(); + const rules = await engine.describeRules(createDescribeOptions(workspace)); + expect(rules).toEqual([]); + }); + + it('throws when the target org cannot be authenticated', async () => { + authService.initialize.mockRejectedValue(new Error(getMessage('OrgNotFound', 'farmor', 'farmor'))); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + await expect(createEngine().describeRules(createDescribeOptions(workspace))) + .rejects.toThrow(getMessage('OrgNotFound', 'farmor', 'farmor')); + }); + + it('skips runRules instead of throwing when analyze fails', async () => { + apiClient.analyze.mockRejectedValue(new Error('HTTP 404')); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(results.violations).toEqual([]); + expect(results.insights).toEqual(expect.objectContaining({ + status: 'skipped', + error: expect.objectContaining({ code: 'FLOW_ANALYZER_UNAVAILABLE' }) + })); + }); + + it('returns zero violations when no rules are selected', async () => { + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules([], createRunOptions(workspace)); + expect(results.violations).toHaveLength(0); + expect(apiClient.analyze).not.toHaveBeenCalled(); + }); + + it('returns zero violations when no Flow files are targeted', async () => { + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-no-flows')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(results.violations).toHaveLength(0); + expect(apiClient.analyze).not.toHaveBeenCalled(); + }); + + it('describes rules without a workspace by calling the remote catalog', async () => { + const rules = await createEngine().describeRules(createDescribeOptions()); + expect(rules).toEqual(SAMPLE_RULES); + expect(authService.initialize).toHaveBeenCalled(); + }); + + it('filters unselected catalog rules and maps selected findings to XML locations', async () => { + apiClient.analyze.mockResolvedValue([ + { + ruleName: 'UnusedVariable', + message: 'Remove unused var', + relativeFilePath: 'Sample.flow-meta.xml', + elementApiName: 'unusedVar', + elementType: 'variables' + }, + { + ruleName: 'UnusedVariable', + message: 'should be filtered if not selected - still known', + relativeFilePath: 'Sample.flow-meta.xml', + elementApiName: 'unusedVar' + } + ]); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(apiClient.analyze).toHaveBeenCalledWith(expect.any(Array), { rules: ['UnusedVariable'] }); + expect(results.violations).toHaveLength(2); + expect(results.violations[0].codeLocations[0].startLine).toBeGreaterThan(1); + expect(results.violations[0].codeLocations[0].file).toContain('Sample.flow-meta.xml'); + }); + + it('fails on findings whose rule is absent from the catalog', async () => { + apiClient.analyze.mockResolvedValue([{ + ruleName: 'NotInCatalog', + message: 'bad', + relativeFilePath: 'Sample.flow-meta.xml', + elementApiName: 'unusedVar' + }]); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(results.insights).toEqual(expect.objectContaining({ + status: 'skipped', + error: expect.objectContaining({ + message: getMessage('UnknownRuleInFindings', 'NotInCatalog') + }) + })); + }); + + it('fails on findings whose path was not submitted', async () => { + apiClient.analyze.mockResolvedValue([{ + ruleName: 'UnusedVariable', + message: 'bad', + relativeFilePath: 'Other.flow-meta.xml', + elementApiName: 'unusedVar' + }]); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(results.insights).toEqual(expect.objectContaining({ + status: 'skipped', + error: expect.objectContaining({ + message: getMessage('PathNotSubmitted', 'Other.flow-meta.xml') + }) + })); + }); + + it('uses a file-level 1:1 anchor when the element cannot be mapped', async () => { + apiClient.analyze.mockResolvedValue([{ + ruleName: 'UnusedVariable', + message: 'unmapped', + relativeFilePath: 'Sample.flow-meta.xml', + elementApiName: 'doesNotExist', + elementType: 'variables' + }]); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(results.violations[0].codeLocations[0].startLine).toEqual(1); + expect(results.violations[0].codeLocations[0].comment).toContain('File-level 1:1 anchor'); + }); + + it('uses a file-level 1:1 anchor when elementApiName is empty', async () => { + apiClient.analyze.mockResolvedValue([{ + ruleName: 'UnusedVariable', + message: 'unmapped', + relativeFilePath: 'Sample.flow-meta.xml', + elementApiName: '' + }]); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(results.violations[0].codeLocations[0].startLine).toEqual(1); + }); + + it('creates a unique temp folder per helper call', () => { + const first = createRunOptions(new Workspace('id', [os.tmpdir()])); + const second = createRunOptions(new Workspace('id', [os.tmpdir()])); + expect(first.workingFolder).not.toEqual(second.workingFolder); + expect(fs.existsSync(TEST_DATA_FOLDER)).toBe(true); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts new file mode 100644 index 00000000..ae86a4ec --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts @@ -0,0 +1,154 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Workspace } from '@salesforce/code-analyzer-engine-api'; +import { FlowFileCollector } from '../src/services/FlowFileCollector'; +import { getMessage } from '../src/messages'; + +describe('FlowFileCollector', () => { + const collector = new FlowFileCollector(2, 1000, 2000); + const sampleDir = path.join(__dirname, 'test-data', 'example-workspaces', 'contains-one-flow'); + + it('collects workspace-relative flow paths', async () => { + const workspace = new Workspace('id', [sampleDir]); + const files = await collector.collect(workspace); + expect(files).toHaveLength(1); + expect(files[0].relativeFilePath).toEqual('Sample.flow-meta.xml'); + expect(files[0].content).toContain('unusedVar'); + }); + + describe('with targeted subsets of a workspace', () => { + let root: string; + beforeAll(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'flow-targets-'))); + fs.mkdirSync(path.join(root, 'flows', 'nested'), { recursive: true }); + for (const rel of ['flows/A.flow-meta.xml', 'flows/B.flow-meta.xml', 'flows/nested/C.flow-meta.xml']) { + fs.writeFileSync(path.join(root, rel), ''); + } + }); + afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + + const collectPaths = async (targets: string[]): Promise => { + const workspace = new Workspace('id', [root], targets); + return (await collector.collect(workspace)).map(f => f.relativeFilePath).sort(); + }; + + it('collects only the single flow that is targeted', async () => { + expect(await collectPaths([path.join(root, 'flows', 'A.flow-meta.xml')])) + .toEqual(['flows/A.flow-meta.xml']); + }); + + it('collects only the multiple flows that are targeted', async () => { + expect(await collectPaths([ + path.join(root, 'flows', 'A.flow-meta.xml'), + path.join(root, 'flows', 'nested', 'C.flow-meta.xml') + ])).toEqual(['flows/A.flow-meta.xml', 'flows/nested/C.flow-meta.xml']); + }); + + it('collects all flows under a targeted folder', async () => { + expect(await collectPaths([path.join(root, 'flows', 'nested')])) + .toEqual(['flows/nested/C.flow-meta.xml']); + }); + }); + + it('rejects oversized files', async () => { + const tiny = new FlowFileCollector(2, 10, 300); + const workspace = new Workspace('id', [sampleDir]); + await expect(tiny.collect(workspace)).rejects.toThrow('exceeds the per-file limit'); + }); + + it('rejects too many files', async () => { + const limited = new FlowFileCollector(1, 200, 300); + const extra = path.join(sampleDir, 'Other.flow-meta.xml'); + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => [ + path.join(sampleDir, 'Sample.flow-meta.xml'), + extra + ] + } as unknown as Workspace; + await expect(limited.collect(workspace)).rejects.toThrow(getMessage('ExcessiveFileCount', 2, 1)); + }); + + it('rejects unsupported file types supplied as targets', async () => { + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => [path.join(sampleDir, 'notes.txt')] + } as unknown as Workspace; + fs.writeFileSync(path.join(sampleDir, 'notes.txt'), 'not a flow'); + await expect(collector.collect(workspace)).resolves.toEqual([]); + }); + + it('rejects files that resolve outside the workspace root', async () => { + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => [path.join(sampleDir, '..', 'contains-one-flow', '..', 'outside.flow-meta.xml')] + } as unknown as Workspace; + await expect(collector.collect(workspace)).rejects.toThrow('absolute path'); + }); + + it('rejects a realpath that does not match the submitted path', async () => { + const collectorWithFs = new FlowFileCollector(5, 1000, 1000, { + lstatSync: () => ({ isSymbolicLink: () => false }), + readFileSync: () => '', + realpathSync: () => '/some/other/path.flow-meta.xml' + }); + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => [path.join(sampleDir, 'Sample.flow-meta.xml')] + } as unknown as Workspace; + await expect(collectorWithFs.collect(workspace)).rejects.toThrow('symbolic links are not allowed'); + }); + + it('rejects a missing workspace root', async () => { + const workspace = { + getWorkspaceRoot: () => null, + getTargetedFiles: async () => [path.join(sampleDir, 'Sample.flow-meta.xml')] + } as unknown as Workspace; + await expect(collector.collect(workspace)).rejects.toThrow('absolute path'); + }); + + it('rejects an oversized total payload', async () => { + const limited = new FlowFileCollector(5, 10000, 10); + const workspace = new Workspace('id', [sampleDir]); + await expect(limited.collect(workspace)).rejects.toThrow('exceeds the limit of 10'); + }); + + it('rejects case-colliding relative paths', async () => { + const colliding = new FlowFileCollector(5, 1000, 1000); + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => [ + path.join(sampleDir, 'Sample.flow-meta.xml'), + path.join(sampleDir, 'sample.flow-meta.xml') + ] + } as unknown as Workspace; + await expect(colliding.collect(workspace)).rejects.toThrow( + getMessage('DuplicatePathRejected', 'sample.flow-meta.xml')); + }); + + it('rejects symbolic links', async () => { + const collectorWithFs = new FlowFileCollector(5, 1000, 1000, { + lstatSync: () => ({ isSymbolicLink: () => true }) as fs.Stats, + readFileSync: fs.readFileSync, + realpathSync: fs.realpathSync + }); + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => [path.join(sampleDir, 'Sample.flow-meta.xml')] + } as unknown as Workspace; + await expect(collectorWithFs.collect(workspace)).rejects.toThrow('symbolic links are not allowed'); + }); + + it('rejects relative target paths that are not absolute on disk', async () => { + const workspace = { + getWorkspaceRoot: () => sampleDir, + getTargetedFiles: async () => ['Sample.flow-meta.xml'] + } as unknown as Workspace; + await expect(collector.collect(workspace)).rejects.toThrow('absolute path'); + }); + + it('uses a unique temp directory helper so os import stays live', () => { + expect(os.tmpdir().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/location-mapper.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/location-mapper.test.ts new file mode 100644 index 00000000..f70ccf87 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/location-mapper.test.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { FindingLocationMapper } from '../src/services/FindingLocationMapper'; +import { CollectedFlowFile } from '../src/types'; + +describe('FindingLocationMapper', () => { + const mapper = new FindingLocationMapper(); + const xml = [ + '', + '', + ' ', + ' unusedVar', + ' ', + '' + ].join('\n'); + const file: CollectedFlowFile = { + absolutePath: path.join('tmp', 'Sample.flow-meta.xml'), + relativeFilePath: 'Sample.flow-meta.xml', + content: xml + }; + + it('maps an element name to line and column', () => { + const location = mapper.map({ + ruleName: 'R', + message: 'm', + relativeFilePath: file.relativeFilePath, + elementApiName: 'unusedVar' + }, file); + expect(location.startLine).toEqual(4); + expect(location.usedFileLevelAnchor).toBe(false); + }); + + it('maps using an affectedField hint when provided', () => { + const location = mapper.map({ + ruleName: 'R', + message: 'm', + relativeFilePath: file.relativeFilePath, + elementApiName: 'unusedVar', + affectedField: 'name' + }, file); + expect(location.startLine).toEqual(4); + expect(location.usedFileLevelAnchor).toBe(false); + }); + + it('falls back to a documented 1:1 file-level anchor', () => { + const location = mapper.map({ + ruleName: 'R', + message: 'm', + relativeFilePath: file.relativeFilePath, + elementApiName: 'missing', + elementType: 'variables', + affectedField: 'name' + }, file); + expect(location.startLine).toEqual(1); + expect(location.startColumn).toEqual(1); + expect(location.usedFileLevelAnchor).toBe(true); + expect(location.comment).toContain('File-level 1:1 anchor'); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/metadata-archive.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/metadata-archive.test.ts new file mode 100644 index 00000000..43d85ab1 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/metadata-archive.test.ts @@ -0,0 +1,13 @@ +import { createMetadataArchive } from '../src/services/MetadataArchiveBuilder'; + +describe('createMetadataArchive', () => { + it('builds a zip containing workspace-relative flow paths', async () => { + const zip = await createMetadataArchive([{ + absolutePath: '/tmp/Sample.flow-meta.xml', + relativeFilePath: 'flows/Sample.flow-meta.xml', + content: '' + }]); + expect(zip.subarray(0, 2).toString()).toEqual('PK'); + expect(zip.length).toBeGreaterThan(20); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts new file mode 100644 index 00000000..5a853570 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts @@ -0,0 +1,85 @@ +import { ConfigObject, ConfigValueExtractor, EnginePluginV1 } from '@salesforce/code-analyzer-engine-api'; +import { FlowAnalyzerEnginePlugin } from '../src'; +import { FlowAnalyzerEngine } from '../src/engine'; +import { getMessage } from '../src/messages'; +import { ENGINE_NAME } from '../src/constants'; + +describe('Tests for the FlowAnalyzerEnginePlugin', () => { + let plugin: EnginePluginV1; + + beforeAll(() => { + plugin = new FlowAnalyzerEnginePlugin(); + }); + + it('returns flowAnalyzer from getAvailableEngineNames', () => { + expect(plugin.getAvailableEngineNames()).toEqual([ENGINE_NAME]); + }); + + it('throws when createEngine is called with an invalid name', async () => { + await expect(plugin.createEngine('error_engine', {})).rejects.toThrow( + getMessage('UnsupportedEngineName', 'error_engine')); + }); + + it('throws when describeEngineConfig is called with an invalid name', () => { + expect(() => plugin.describeEngineConfig('error_engine')).toThrow( + getMessage('UnsupportedEngineName', 'error_engine')); + }); + + it('describes the flowAnalyzer config schema', () => { + const description = plugin.describeEngineConfig(ENGINE_NAME); + expect(description.fieldDescriptions?.target_org.valueType).toEqual('string'); + }); + + it('creates a FlowAnalyzerEngine when target_org is provided', async () => { + const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org' + }, `engines.${ENGINE_NAME}`)); + expect(config.target_org).toEqual('my-org'); + expect(await plugin.createEngine(ENGINE_NAME, config as ConfigObject)).toBeInstanceOf(FlowAnalyzerEngine); + }); + + it('rejects missing target_org', async () => { + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({}, `engines.${ENGINE_NAME}`))) + .rejects.toThrow(getMessage('MissingTargetOrg', `engines.${ENGINE_NAME}.target_org`)); + }); + + it('rejects non-positive numeric config', async () => { + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org', + api_timeout_ms: 0 + }, `engines.${ENGINE_NAME}`))).rejects.toThrow( + getMessage('ConfigValueMustBePositive', `engines.${ENGINE_NAME}.api_timeout_ms`)); + }); + + it('rejects empty target_org', async () => { + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: ' ' + }, `engines.${ENGINE_NAME}`))).rejects.toThrow( + getMessage('MissingTargetOrg', `engines.${ENGINE_NAME}.target_org`)); + }); + + it('rejects non-positive max_file_count, max_file_bytes, and max_payload_bytes', async () => { + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org', + max_file_count: 0 + }, `engines.${ENGINE_NAME}`))).rejects.toThrow( + getMessage('ConfigValueMustBePositive', `engines.${ENGINE_NAME}.max_file_count`)); + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org', + max_file_bytes: -1 + }, `engines.${ENGINE_NAME}`))).rejects.toThrow( + getMessage('ConfigValueMustBePositive', `engines.${ENGINE_NAME}.max_file_bytes`)); + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org', + max_payload_bytes: 0 + }, `engines.${ENGINE_NAME}`))).rejects.toThrow( + getMessage('ConfigValueMustBePositive', `engines.${ENGINE_NAME}.max_payload_bytes`)); + }); + + it('throws when createEngineConfig is called with an invalid name', async () => { + await expect(plugin.createEngineConfig('error_engine', new ConfigValueExtractor({ + target_org: 'my-org' + }, 'engines.error_engine'))).rejects.toThrow( + getMessage('UnsupportedEngineName', 'error_engine')); + }); +}); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-no-flows/dummy-file.txt b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-no-flows/dummy-file.txt new file mode 100644 index 00000000..75c4bb60 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-no-flows/dummy-file.txt @@ -0,0 +1 @@ +This is a dummy file, and it is here so that its parent directory can be pushed to Github. diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml new file mode 100644 index 00000000..bae9ec08 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml @@ -0,0 +1,12 @@ + + + 64.0 + Active + + unusedVar + String + false + false + false + + \ No newline at end of file diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/notes.txt b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/notes.txt new file mode 100644 index 00000000..5113ebf6 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/notes.txt @@ -0,0 +1 @@ +not a flow \ No newline at end of file diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt b/packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt new file mode 100644 index 00000000..5113ebf6 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt @@ -0,0 +1 @@ +not a flow \ No newline at end of file diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-helpers.ts b/packages/code-analyzer-flowanalyzer-engine/test/test-helpers.ts new file mode 100644 index 00000000..6b9bd681 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-helpers.ts @@ -0,0 +1,37 @@ +import { DescribeOptions, RunOptions, Workspace } from "@salesforce/code-analyzer-engine-api"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as process from "node:process"; + +export function changeWorkingDirectoryToPackageRoot() { + let original_working_directory: string; + beforeAll(() => { + // We change the directory to ensure that the config files (which use relative folders from the package root) + // are processed correctly. The project root directory is typically the one used by default, but it may be + // different if the tests are run from the mono-repo's root directory. Lastly, it is better to use the + // package root directory is instead of the test directory since some IDEs (like IntelliJ) fail to collect + // code coverage correctly unless this package root directory is used. + original_working_directory = process.cwd(); + process.chdir(path.resolve(__dirname,'..')); + }); + afterAll(() => { + process.chdir(original_working_directory); + }); +} + +export function createDescribeOptions(workspace?: Workspace): DescribeOptions { + return { + logFolder: os.tmpdir(), + workspace: workspace, + workingFolder: fs.mkdtempSync(path.join(os.tmpdir(),'tmp-')) + } +} + +export function createRunOptions(workspace: Workspace): RunOptions { + return { + logFolder: os.tmpdir(), + workspace: workspace, + workingFolder: fs.mkdtempSync(path.join(os.tmpdir(),'tmp-')) + } +} diff --git a/packages/code-analyzer-flowanalyzer-engine/tsconfig.build.json b/packages/code-analyzer-flowanalyzer-engine/tsconfig.build.json new file mode 100644 index 00000000..91426db6 --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true + }, + "include": [ + "./src" + ], + "references": [ + { + "path": "../code-analyzer-engine-api/tsconfig.build.json" + } + ] +} diff --git a/packages/code-analyzer-flowanalyzer-engine/tsconfig.json b/packages/code-analyzer-flowanalyzer-engine/tsconfig.json new file mode 100644 index 00000000..111c54ac --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": [ + "./src", + "./test" + ] + } From d40c01328a45ffe847fef22587560462f21f702f Mon Sep 17 00:00:00 2001 From: abarseni Date: Wed, 21 Oct 2026 13:11:22 +0200 Subject: [PATCH 2/4] Update Flow Analyzer engine tests for new sample flow Replaces the sample flow fixture with a real flow metadata file and updates the file collector and engine tests to match (new size limits, get_Account element, recordLookups type). Removes the unused sample-flow/notes.txt fixture and updates package-lock.json to register the new workspace package. --- package-lock.json | 266 +++++++++++++++++- .../test/engine.test.ts | 12 +- .../test/file-collector.test.ts | 6 +- .../contains-one-flow/Sample.flow-meta.xml | 50 +++- .../test/test-data/sample-flow/notes.txt | 1 - 5 files changed, 313 insertions(+), 22 deletions(-) delete mode 100644 packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt diff --git a/package-lock.json b/package-lock.json index d70c7b0b..36f25547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1829,6 +1829,10 @@ "resolved": "packages/code-analyzer-flow-engine", "link": true }, + "node_modules/@salesforce/code-analyzer-flowanalyzer-engine": { + "resolved": "packages/code-analyzer-flowanalyzer-engine", + "link": true + }, "node_modules/@salesforce/code-analyzer-pmd-engine": { "resolved": "packages/code-analyzer-pmd-engine", "link": true @@ -6773,9 +6777,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -11922,6 +11926,262 @@ "node": "*" } }, + "packages/code-analyzer-flowanalyzer-engine": { + "name": "@salesforce/code-analyzer-flowanalyzer-engine", + "version": "0.1.0-SNAPSHOT", + "license": "BSD-3-Clause", + "dependencies": { + "@salesforce/code-analyzer-engine-api": "0.42.0", + "@salesforce/core": "^8.31.2", + "archiver": "^7.0.1", + "form-data": "^4.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@types/archiver": "^6.0.4", + "@types/jest": "^30.0.0", + "@types/node": "^20.0.0", + "eslint": "^9.39.5", + "jest": "^30.4.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.12", + "typescript": "^5.9.3", + "typescript-eslint": "^8.67.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "packages/code-analyzer-flowanalyzer-engine/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "packages/code-analyzer-pmd-engine": { "name": "@salesforce/code-analyzer-pmd-engine", "version": "0.46.0", diff --git a/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts index 8afdd0a2..c0599c4f 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts @@ -135,14 +135,14 @@ describe('FlowAnalyzerEngine', () => { ruleName: 'UnusedVariable', message: 'Remove unused var', relativeFilePath: 'Sample.flow-meta.xml', - elementApiName: 'unusedVar', - elementType: 'variables' + elementApiName: 'get_Account', + elementType: 'recordLookups' }, { ruleName: 'UnusedVariable', message: 'should be filtered if not selected - still known', relativeFilePath: 'Sample.flow-meta.xml', - elementApiName: 'unusedVar' + elementApiName: 'get_Account' } ]); const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); @@ -158,7 +158,7 @@ describe('FlowAnalyzerEngine', () => { ruleName: 'NotInCatalog', message: 'bad', relativeFilePath: 'Sample.flow-meta.xml', - elementApiName: 'unusedVar' + elementApiName: 'get_Account' }]); const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); @@ -175,7 +175,7 @@ describe('FlowAnalyzerEngine', () => { ruleName: 'UnusedVariable', message: 'bad', relativeFilePath: 'Other.flow-meta.xml', - elementApiName: 'unusedVar' + elementApiName: 'get_Account' }]); const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); @@ -193,7 +193,7 @@ describe('FlowAnalyzerEngine', () => { message: 'unmapped', relativeFilePath: 'Sample.flow-meta.xml', elementApiName: 'doesNotExist', - elementType: 'variables' + elementType: 'recordLookups' }]); const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); const results = await createEngine().runRules(['UnusedVariable'], createRunOptions(workspace)); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts index ae86a4ec..05d90bb0 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/file-collector.test.ts @@ -6,7 +6,7 @@ import { FlowFileCollector } from '../src/services/FlowFileCollector'; import { getMessage } from '../src/messages'; describe('FlowFileCollector', () => { - const collector = new FlowFileCollector(2, 1000, 2000); + const collector = new FlowFileCollector(2, 5000, 10000); const sampleDir = path.join(__dirname, 'test-data', 'example-workspaces', 'contains-one-flow'); it('collects workspace-relative flow paths', async () => { @@ -14,7 +14,7 @@ describe('FlowFileCollector', () => { const files = await collector.collect(workspace); expect(files).toHaveLength(1); expect(files[0].relativeFilePath).toEqual('Sample.flow-meta.xml'); - expect(files[0].content).toContain('unusedVar'); + expect(files[0].content).toContain('get_Account'); }); describe('with targeted subsets of a workspace', () => { @@ -115,7 +115,7 @@ describe('FlowFileCollector', () => { }); it('rejects case-colliding relative paths', async () => { - const colliding = new FlowFileCollector(5, 1000, 1000); + const colliding = new FlowFileCollector(5, 5000, 10000); const workspace = { getWorkspaceRoot: () => sampleDir, getTargetedFiles: async () => [ diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml index bae9ec08..16a0f6fd 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml @@ -1,12 +1,44 @@ - 64.0 - Active - - unusedVar - String - false - false - false - + 68.0 + Default + AL Flow and Test {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + get_Account + + 0 + 0 + false + true + Account + true + + + 0 + 0 + + get_Account + + + Draft \ No newline at end of file diff --git a/packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt b/packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt deleted file mode 100644 index 5113ebf6..00000000 --- a/packages/code-analyzer-flowanalyzer-engine/test/test-data/sample-flow/notes.txt +++ /dev/null @@ -1 +0,0 @@ -not a flow \ No newline at end of file From 70888a6fd901748ecb6ebeca775ee63f8d69e568 Mon Sep 17 00:00:00 2001 From: abarseni Date: Mon, 2 Nov 2026 14:03:06 +0100 Subject: [PATCH 3/4] Address Flow Analyzer engine review feedback - Always use the configured api_version for Connect requests instead of the CLI connection's version, which may predate the Flow Analyzer endpoints. - Propagate rule catalog failures from describeRules so Core reports the engine error instead of silently selecting zero rules. - Make target_org optional: without it the engine is inactive and offers no rules, so scans without --target-org are unaffected. Auth still never falls back to the default org. - Make the package publishable: add it to the publish-package-to-npm workflow and to the not-yet-published list in validate-changed-package-versions.js. --- .github/workflows/publish-package-to-npm.yml | 8 ++++ .../validate-changed-package-versions.js | 2 + .../README.md | 2 +- .../src/config.ts | 11 ++--- .../src/engine.ts | 21 +++++---- .../src/messages.ts | 13 ++++-- .../src/services/FlowAnalyzerApiClient.ts | 4 +- .../src/services/FlowAnalyzerAuthService.ts | 4 -- .../test/api-client.test.ts | 14 +++++- .../test/auth.test.ts | 8 ---- .../test/engine.test.ts | 46 +++++++++++++++---- .../test/plugin.test.ts | 22 ++++++--- 12 files changed, 104 insertions(+), 51 deletions(-) diff --git a/.github/workflows/publish-package-to-npm.yml b/.github/workflows/publish-package-to-npm.yml index 40698451..c58845da 100644 --- a/.github/workflows/publish-package-to-npm.yml +++ b/.github/workflows/publish-package-to-npm.yml @@ -57,6 +57,11 @@ on: type: boolean required: false default: false + code-analyzer-flowanalyzer-engine: + description: Should the code-analyzer-flowanalyzer-engine package be released? + type: boolean + required: false + default: false dryrun: description: Add --dry-run to npm publish step? (Uncheck to actually publish) type: boolean @@ -118,6 +123,9 @@ jobs: if [ "${{ inputs.code-analyzer-uibundle-engine }}" == "true" ]; then PACKAGES_TO_CHECK_ARR+=('code-analyzer-uibundle-engine') fi + if [ "${{ inputs.code-analyzer-flowanalyzer-engine }}" == "true" ]; then + PACKAGES_TO_CHECK_ARR+=('code-analyzer-flowanalyzer-engine') + fi PACKAGES_TO_CHECK_STR=$(IFS=' '; echo "${PACKAGES_TO_CHECK_ARR[*]}") node ./.github/workflows/publish-package-to-npm/validate-packages-as-releasable.js "$PACKAGES_TO_CHECK_STR" echo "packages_to_release=$PACKAGES_TO_CHECK_STR" >> "$GITHUB_OUTPUT" diff --git a/.node-scripts/validate-changed-package-versions.js b/.node-scripts/validate-changed-package-versions.js index c781d09b..5e2a4a57 100644 --- a/.node-scripts/validate-changed-package-versions.js +++ b/.node-scripts/validate-changed-package-versions.js @@ -115,6 +115,8 @@ function getLatestReleasedVersion(changedPackage) { function isPackageThatHasNotPublished(changedPackage) { return [ "packages/ENGINE-TEMPLATE", + // TODO: remove once @salesforce/code-analyzer-flowanalyzer-engine is published to npm + "packages/code-analyzer-flowanalyzer-engine" ].includes(changedPackage.replace("\\","/")); } diff --git a/packages/code-analyzer-flowanalyzer-engine/README.md b/packages/code-analyzer-flowanalyzer-engine/README.md index 9941ffc9..c2931c2a 100644 --- a/packages/code-analyzer-flowanalyzer-engine/README.md +++ b/packages/code-analyzer-flowanalyzer-engine/README.md @@ -14,7 +14,7 @@ engines: target_org: myOrg ``` -`target_org` is required. The CLI `--target-org` flag should populate it. The engine never falls back to the default Salesforce CLI org. +The CLI `--target-org` flag populates `target_org`. When `target_org` isn't set, the engine is inactive: it offers no rules and makes no network calls, so scans without `--target-org` are unaffected. The engine never falls back to the default Salesforce CLI org. ## Commands diff --git a/packages/code-analyzer-flowanalyzer-engine/src/config.ts b/packages/code-analyzer-flowanalyzer-engine/src/config.ts index cd207bda..5cd88551 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/config.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/config.ts @@ -11,7 +11,8 @@ import { } from './constants'; export type FlowAnalyzerEngineConfig = { - target_org: string; + // Optional: when absent the engine is inactive (offers no rules) so scans without --target-org are unaffected. + target_org?: string; api_timeout_ms: number; api_version: string; rules_path: string; @@ -22,7 +23,6 @@ export type FlowAnalyzerEngineConfig = { }; export const DEFAULT_FLOW_ANALYZER_ENGINE_CONFIG: FlowAnalyzerEngineConfig = { - target_org: '', api_timeout_ms: DEFAULT_API_TIMEOUT_MS, api_version: DEFAULT_API_VERSION, rules_path: DEFAULT_RULES_PATH, @@ -92,10 +92,7 @@ export async function validateAndNormalizeConfig( 'max_payload_bytes' ]); - const targetOrg: string | undefined = configValueExtractor.extractString('target_org'); - if (!targetOrg || targetOrg.trim().length === 0) { - throw new Error(getMessage('MissingTargetOrg', configValueExtractor.getFieldPath('target_org'))); - } + const targetOrg: string | undefined = configValueExtractor.extractString('target_org')?.trim() || undefined; const apiTimeoutMs: number = configValueExtractor.extractNumber( 'api_timeout_ms', @@ -137,7 +134,7 @@ export async function validateAndNormalizeConfig( } return { - target_org: targetOrg.trim(), + target_org: targetOrg, api_timeout_ms: apiTimeoutMs, api_version: apiVersion, rules_path: rulesPath, diff --git a/packages/code-analyzer-flowanalyzer-engine/src/engine.ts b/packages/code-analyzer-flowanalyzer-engine/src/engine.ts index 1d092f06..5bf7c12a 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/engine.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/engine.ts @@ -68,6 +68,12 @@ export class FlowAnalyzerEngine extends Engine { async describeRules(describeOptions: DescribeOptions): Promise { this.emitDescribeRulesProgressEvent(0); + if (!this.config.target_org) { + // The engine is opt-in: without a target org it offers no rules, so scans without --target-org are unaffected. + this.emitLogEvent(LogLevel.Debug, getMessage('NoTargetOrg', `engines.${ENGINE_NAME}.target_org`)); + this.emitDescribeRulesProgressEvent(100); + return []; + } if (describeOptions.workspace) { const collected = await this.fileCollector.collect(describeOptions.workspace); if (collected.length === 0) { @@ -79,14 +85,9 @@ export class FlowAnalyzerEngine extends Engine { await this.ensureAuthenticated(); this.emitDescribeRulesProgressEvent(50); - try { - this.catalogCache = await this.apiClient.describeRules(); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - this.emitLogEvent(LogLevel.Error, getMessage('CatalogUnavailable', detail)); - this.emitDescribeRulesProgressEvent(100); - return []; - } + // Let catalog failures propagate so Code Analyzer Core reports the engine as uninstantiable, + // rather than silently selecting zero rules and reporting a successful run. + this.catalogCache = await this.apiClient.describeRules(); this.emitDescribeRulesProgressEvent(100); return this.catalogCache; } @@ -161,6 +162,10 @@ export class FlowAnalyzerEngine extends Engine { } private async ensureAuthenticated(): Promise { + if (!this.config.target_org) { + // Never let auth fall back to the default Salesforce CLI org. + throw new Error(getMessage('MissingTargetOrg', `engines.${ENGINE_NAME}.target_org`)); + } await this.authService.initialize({ targetOrg: this.config.target_org }); } diff --git a/packages/code-analyzer-flowanalyzer-engine/src/messages.ts b/packages/code-analyzer-flowanalyzer-engine/src/messages.ts index 64a2b5e3..d7a45765 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/messages.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/messages.ts @@ -2,13 +2,13 @@ import { getMessageFromCatalog } from '@salesforce/code-analyzer-engine-api'; const MESSAGE_CATALOG: { [key: string]: string } = { UnsupportedEngineName: - `The FlowAnalyzerEnginePlugin does not support an engine with name '%s'.`, + `The FlowAnalyzerEnginePlugin doesn't support an engine named '%s'.`, ConfigOverview: - `Configuration for the Flow Analyzer engine. Authentication uses the Salesforce CLI session for the required --target-org value.`, + `Configuration for the Flow Analyzer engine. Authentication uses the Salesforce CLI session for the --target-org value.`, ConfigFieldDescription_target_org: - `Target Salesforce org alias or username. Required. Typically set by the CLI --target-org flag. Flow Analyzer never falls back to the default org.`, + `Target Salesforce org alias or username. Typically set by the CLI --target-org flag. When not set, Flow Analyzer is inactive and offers no rules. Flow Analyzer never falls back to the default org.`, ConfigFieldDescription_api_timeout_ms: `Maximum time to wait for Flow Analyzer API responses, in milliseconds. Default: 300000 (5 minutes).`, @@ -34,6 +34,9 @@ const MESSAGE_CATALOG: { [key: string]: string } = { MissingTargetOrg: `Flow Analyzer requires an explicit target org. Set '%s' or pass --target-org .`, + NoTargetOrg: + `Flow Analyzer is inactive because no target org is configured. To enable it, set '%s' or pass --target-org .`, + ConfigValueMustBePositive: `The value of '%s' must be greater than 0.`, @@ -68,10 +71,10 @@ const MESSAGE_CATALOG: { [key: string]: string } = { `Flow Analyzer received a malformed analyze response from the target org.`, UnknownRuleInFindings: - `Flow Analyzer returned finding for unknown rule '%s'. The engine will not silently drop unknown rules.`, + `Flow Analyzer returned a finding for unknown rule '%s'. The engine won't silently drop unknown rules.`, PathNotSubmitted: - `Flow Analyzer returned finding for path '%s', which was not submitted.`, + `Flow Analyzer returned a finding for path '%s', which was not submitted.`, AbsolutePathRejected: `Flow Analyzer rejected absolute path '%s'. Submit workspace-relative Flow paths only.`, diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts index 0f121065..651ef51e 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts @@ -133,8 +133,8 @@ export class FlowAnalyzerApiClient { } private async requestJson(method: 'GET' | 'POST', resourcePath: string, body?: unknown): Promise { - const version = this.authService.getApiVersion() || this.apiVersion; - const url = `${this.authService.getInstanceUrl()}/services/data/v${version}${resourcePath}`; + // Always use the engine's configured version: the CLI connection's version may predate the Flow Analyzer endpoints. + const url = `${this.authService.getInstanceUrl()}/services/data/v${this.apiVersion}${resourcePath}`; this.emitLogEvent(LogLevel.Fine, `Flow Analyzer ${method} ${url}`); const controller = new AbortController(); diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts index 6c805561..69713016 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts @@ -51,8 +51,4 @@ export class FlowAnalyzerAuthService { getInstanceUrl(): string { return this.getConnection().instanceUrl; } - - getApiVersion(): string { - return this.getConnection().version || '64.0'; - } } diff --git a/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts index eb3af421..6f00b36f 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts @@ -11,7 +11,8 @@ describe('FlowAnalyzerApiClient', () => { getAccessToken: () => token, refreshAccessToken, getInstanceUrl: () => 'https://example.my.salesforce.com', - getApiVersion: () => '68.0' + // Simulates an older CLI connection version; requests must still use the engine's configured version. + getConnection: () => ({ version: '60.0' }) } as unknown as FlowAnalyzerAuthService; const sampleFiles: CollectedFlowFile[] = [{ @@ -97,6 +98,17 @@ describe('FlowAnalyzerApiClient', () => { .rejects.toThrow(getMessage('CatalogMalformed')); }); + it('uses the configured api version rather than the connection version', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ rules: [] }) + }); + await createClient(fetchImpl as unknown as typeof fetch).describeRules(); + expect(fetchImpl.mock.calls[0][0]).toEqual( + 'https://example.my.salesforce.com/services/data/v68.0/flowbuilder/flow-analyzer/rules'); + }); + it('posts a multipart ZIP to the metadata-archive endpoint', async () => { const fetchImpl = jest.fn().mockResolvedValue({ ok: true, diff --git a/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts index 3c7c8f1f..61ef6ad7 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts @@ -26,7 +26,6 @@ describe('FlowAnalyzerAuthService', () => { expect(Org.create).toHaveBeenCalledWith({ aliasOrUsername: 'myorg' }); expect(authService.getInstanceUrl()).toEqual('https://test.salesforce.com'); expect(authService.getAccessToken()).toEqual('mock_access_token'); - expect(authService.getApiVersion()).toEqual('64.0'); }); it('does not fall back to the default org', async () => { @@ -47,11 +46,4 @@ describe('FlowAnalyzerAuthService', () => { await authService.initialize({ targetOrg: 'myorg' }); expect(() => authService.getAccessToken()).toThrow(getMessage('AccessTokenUnavailable')); }); - - it('defaults api version when the connection has none', async () => { - const mockOrg = { getConnection: jest.fn().mockReturnValue({ ...mockConnection, version: undefined }) }; - (Org.create as jest.Mock).mockResolvedValue(mockOrg); - await authService.initialize({ targetOrg: 'myorg' }); - expect(authService.getApiVersion()).toEqual('64.0'); - }); }); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts index c0599c4f..f8f634e4 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/engine.test.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { RuleDescription, SeverityLevel, Workspace } from '@salesforce/code-analyzer-engine-api'; +import { EventType, LogEvent, LogLevel, RuleDescription, SeverityLevel, Workspace } from '@salesforce/code-analyzer-engine-api'; import { FlowAnalyzerEngine } from '../src/engine'; import { FlowAnalyzerEngineConfig } from '../src/config'; import { FlowAnalyzerAuthService } from '../src/services/FlowAnalyzerAuthService'; @@ -51,8 +51,8 @@ describe('FlowAnalyzerEngine', () => { } as unknown as jest.Mocked; }); - function createEngine(): FlowAnalyzerEngine { - return new FlowAnalyzerEngine(createConfig(), { + function createEngine(configOverrides: Partial = {}): FlowAnalyzerEngine { + return new FlowAnalyzerEngine(createConfig(configOverrides), { authService, apiClient, fileCollector: new FlowFileCollector(10, 10000, 20000), @@ -69,6 +69,37 @@ describe('FlowAnalyzerEngine', () => { expect(version).toMatch(/\d+\.\d+\.\d+.*/); }); + it('is inactive and offers no rules when no target org is configured', async () => { + const engine = createEngine({ target_org: undefined }); + const logEvents: LogEvent[] = []; + engine.onEvent(EventType.LogEvent, (event: LogEvent) => logEvents.push(event)); + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + + const rules = await engine.describeRules(createDescribeOptions(workspace)); + + expect(rules).toEqual([]); + expect(authService.initialize).not.toHaveBeenCalled(); + expect(apiClient.describeRules).not.toHaveBeenCalled(); + expect(logEvents).toContainEqual(expect.objectContaining({ + logLevel: LogLevel.Debug, + message: getMessage('NoTargetOrg', 'engines.flowAnalyzer.target_org') + })); + expect(logEvents.filter(e => e.logLevel <= LogLevel.Warn)).toEqual([]); + }); + + it('skips runRules without authenticating against the default org when no target org is configured', async () => { + const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); + const results = await createEngine({ target_org: undefined }).runRules(['UnusedVariable'], createRunOptions(workspace)); + expect(authService.initialize).not.toHaveBeenCalled(); + expect(apiClient.analyze).not.toHaveBeenCalled(); + expect(results.insights).toEqual(expect.objectContaining({ + status: 'skipped', + error: expect.objectContaining({ + message: getMessage('MissingTargetOrg', 'engines.flowAnalyzer.target_org') + }) + })); + }); + it('returns no rules when the workspace has no Flow files', async () => { const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-no-flows')]); const rules = await createEngine().describeRules(createDescribeOptions(workspace)); @@ -83,12 +114,11 @@ describe('FlowAnalyzerEngine', () => { expect(authService.initialize).toHaveBeenCalledWith({ targetOrg: 'my-org' }); }); - it('returns no rules instead of throwing when the catalog cannot be retrieved', async () => { - apiClient.describeRules.mockRejectedValue(new Error('HTTP 404')); + it('throws when the catalog cannot be retrieved so Core reports the engine error', async () => { + apiClient.describeRules.mockRejectedValue(new Error(getMessage('CatalogUnavailable', 'HTTP 404'))); const workspace = new Workspace('id', [path.join(TEST_DATA_FOLDER, 'example-workspaces', 'contains-one-flow')]); - const engine = createEngine(); - const rules = await engine.describeRules(createDescribeOptions(workspace)); - expect(rules).toEqual([]); + await expect(createEngine().describeRules(createDescribeOptions(workspace))) + .rejects.toThrow(getMessage('CatalogUnavailable', 'HTTP 404')); }); it('throws when the target org cannot be authenticated', async () => { diff --git a/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts index 5a853570..1300edce 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts @@ -38,9 +38,10 @@ describe('Tests for the FlowAnalyzerEnginePlugin', () => { expect(await plugin.createEngine(ENGINE_NAME, config as ConfigObject)).toBeInstanceOf(FlowAnalyzerEngine); }); - it('rejects missing target_org', async () => { - await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({}, `engines.${ENGINE_NAME}`))) - .rejects.toThrow(getMessage('MissingTargetOrg', `engines.${ENGINE_NAME}.target_org`)); + it('creates a FlowAnalyzerEngine without target_org so scans without --target-org are not broken', async () => { + const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({}, `engines.${ENGINE_NAME}`)); + expect(config.target_org).toBeUndefined(); + expect(await plugin.createEngine(ENGINE_NAME, config as ConfigObject)).toBeInstanceOf(FlowAnalyzerEngine); }); it('rejects non-positive numeric config', async () => { @@ -51,11 +52,18 @@ describe('Tests for the FlowAnalyzerEnginePlugin', () => { getMessage('ConfigValueMustBePositive', `engines.${ENGINE_NAME}.api_timeout_ms`)); }); - it('rejects empty target_org', async () => { - await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + it('treats a blank target_org as not configured', async () => { + const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ target_org: ' ' - }, `engines.${ENGINE_NAME}`))).rejects.toThrow( - getMessage('MissingTargetOrg', `engines.${ENGINE_NAME}.target_org`)); + }, `engines.${ENGINE_NAME}`)); + expect(config.target_org).toBeUndefined(); + }); + + it('trims target_org', async () => { + const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: ' my-org ' + }, `engines.${ENGINE_NAME}`)); + expect(config.target_org).toEqual('my-org'); }); it('rejects non-positive max_file_count, max_file_bytes, and max_payload_bytes', async () => { From 6a33e0129daecb5a8246e6a53ce9813edf8a9a7f Mon Sep 17 00:00:00 2001 From: abarseni Date: Mon, 2 Nov 2026 18:22:55 +0100 Subject: [PATCH 4/4] Use the org's API version for Flow Analyzer requests, never below 68.0 An explicitly configured api_version always wins. Otherwise requests use the target org's API version, but never one older than 68.0 (the first version exposing the Flow Analyzer endpoints), so older CLI connections still work and newer orgs (e.g. 69.0) use their own version. Malformed api_version values are now rejected. --- .../README.md | 4 +- .../src/config.ts | 13 ++++--- .../src/constants.ts | 4 +- .../src/messages.ts | 5 ++- .../src/services/FlowAnalyzerApiClient.ts | 20 ++++++++-- .../src/services/FlowAnalyzerAuthService.ts | 4 ++ .../test/api-client.test.ts | 38 +++++++++++++++---- .../test/auth.test.ts | 8 ++++ .../test/plugin.test.ts | 23 +++++++++++ 9 files changed, 98 insertions(+), 21 deletions(-) diff --git a/packages/code-analyzer-flowanalyzer-engine/README.md b/packages/code-analyzer-flowanalyzer-engine/README.md index c2931c2a..e3563f0d 100644 --- a/packages/code-analyzer-flowanalyzer-engine/README.md +++ b/packages/code-analyzer-flowanalyzer-engine/README.md @@ -2,7 +2,7 @@ Salesforce Code Analyzer engine named `flowAnalyzer`. It sends local `.flow` / `.flow-meta.xml` files to an org-backed Connect API and maps findings back onto the original XML. -Analyze POST: `/services/data/v68.0/flowbuilder/flow-analyzer/analysis/metadata-archive` (`multipart/form-data` ZIP in `fileData`). +Analyze POST: `/services/data/vXX.X/flowbuilder/flow-analyzer/analysis/metadata-archive` (`multipart/form-data` ZIP in `fileData`). This package does **not** replace the local Python `flow` engine. @@ -14,6 +14,8 @@ engines: target_org: myOrg ``` +`vXX.X` is the target org's API version, but never lower than 68.0, the first version with the Flow Analyzer endpoints. To pin a version, set `api_version` (for example `api_version: "69.0"`). + The CLI `--target-org` flag populates `target_org`. When `target_org` isn't set, the engine is inactive: it offers no rules and makes no network calls, so scans without `--target-org` are unaffected. The engine never falls back to the default Salesforce CLI org. ## Commands diff --git a/packages/code-analyzer-flowanalyzer-engine/src/config.ts b/packages/code-analyzer-flowanalyzer-engine/src/config.ts index 5cd88551..cc3aafbd 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/config.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/config.ts @@ -3,7 +3,6 @@ import { getMessage } from './messages'; import { DEFAULT_ANALYZE_PATH, DEFAULT_API_TIMEOUT_MS, - DEFAULT_API_VERSION, DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_FILE_COUNT, DEFAULT_MAX_PAYLOAD_BYTES, @@ -14,7 +13,8 @@ export type FlowAnalyzerEngineConfig = { // Optional: when absent the engine is inactive (offers no rules) so scans without --target-org are unaffected. target_org?: string; api_timeout_ms: number; - api_version: string; + // Optional: when absent, requests use the org's API version (but at least MIN_API_VERSION). + api_version?: string; rules_path: string; analyze_path: string; max_file_count: number; @@ -24,7 +24,6 @@ export type FlowAnalyzerEngineConfig = { export const DEFAULT_FLOW_ANALYZER_ENGINE_CONFIG: FlowAnalyzerEngineConfig = { api_timeout_ms: DEFAULT_API_TIMEOUT_MS, - api_version: DEFAULT_API_VERSION, rules_path: DEFAULT_RULES_PATH, analyze_path: DEFAULT_ANALYZE_PATH, max_file_count: DEFAULT_MAX_FILE_COUNT, @@ -48,7 +47,7 @@ export const FLOW_ANALYZER_ENGINE_CONFIG_DESCRIPTION: ConfigDescription = { api_version: { descriptionText: getMessage('ConfigFieldDescription_api_version'), valueType: 'string', - defaultValue: DEFAULT_API_VERSION + defaultValue: null }, rules_path: { descriptionText: getMessage('ConfigFieldDescription_rules_path'), @@ -102,8 +101,10 @@ export async function validateAndNormalizeConfig( throw new Error(getMessage('ConfigValueMustBePositive', configValueExtractor.getFieldPath('api_timeout_ms'))); } - const apiVersion: string = configValueExtractor.extractString('api_version', DEFAULT_API_VERSION) - ?? DEFAULT_API_VERSION; + const apiVersion: string | undefined = configValueExtractor.extractString('api_version')?.trim() || undefined; + if (apiVersion !== undefined && !/^\d+\.0$/.test(apiVersion)) { + throw new Error(getMessage('InvalidApiVersion', configValueExtractor.getFieldPath('api_version'), apiVersion)); + } const rulesPath: string = configValueExtractor.extractString('rules_path', DEFAULT_RULES_PATH) ?? DEFAULT_RULES_PATH; const analyzePath: string = configValueExtractor.extractString('analyze_path', DEFAULT_ANALYZE_PATH) diff --git a/packages/code-analyzer-flowanalyzer-engine/src/constants.ts b/packages/code-analyzer-flowanalyzer-engine/src/constants.ts index 8bddea51..7a3596c2 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/constants.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/constants.ts @@ -2,7 +2,9 @@ export const ENGINE_NAME = 'flowAnalyzer'; export const FLOW_FILE_EXTENSIONS = ['.flow', '.flow-meta.xml']; -export const DEFAULT_API_VERSION = '68.0'; +// Lowest REST API version exposing the Flow Analyzer Connect endpoints. +// Unless api_version is configured, requests use the org's version, but never anything lower than this. +export const MIN_API_VERSION = '68.0'; export const DEFAULT_RULES_PATH = '/flowbuilder/flow-analyzer/rules'; diff --git a/packages/code-analyzer-flowanalyzer-engine/src/messages.ts b/packages/code-analyzer-flowanalyzer-engine/src/messages.ts index d7a45765..a277bf7d 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/messages.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/messages.ts @@ -14,7 +14,7 @@ const MESSAGE_CATALOG: { [key: string]: string } = { `Maximum time to wait for Flow Analyzer API responses, in milliseconds. Default: 300000 (5 minutes).`, ConfigFieldDescription_api_version: - `Salesforce REST API version used for Connect calls. Default: 68.0.`, + `Salesforce REST API version used for Connect calls, for example '69.0'. Default: the target org's API version, but never lower than 68.0.`, ConfigFieldDescription_rules_path: `Connect path for the rules catalog endpoint. Default: /flowbuilder/flow-analyzer/rules.`, @@ -37,6 +37,9 @@ const MESSAGE_CATALOG: { [key: string]: string } = { NoTargetOrg: `Flow Analyzer is inactive because no target org is configured. To enable it, set '%s' or pass --target-org .`, + InvalidApiVersion: + `The value of '%s' must be an API version such as '69.0', but was '%s'.`, + ConfigValueMustBePositive: `The value of '%s' must be greater than 0.`, diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts index 651ef51e..30977af5 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerApiClient.ts @@ -1,5 +1,6 @@ import { LogLevel, RuleDescription, SeverityLevel } from '@salesforce/code-analyzer-engine-api'; import FormData from 'form-data'; +import { MIN_API_VERSION } from '../constants'; import { getMessage } from '../messages'; import { CollectedFlowFile, @@ -15,7 +16,7 @@ import { createMetadataArchive } from './MetadataArchiveBuilder'; export class FlowAnalyzerApiClient { private readonly authService: FlowAnalyzerAuthService; private readonly timeoutMs: number; - private readonly apiVersion: string; + private readonly apiVersion?: string; private readonly rulesPath: string; private readonly analyzePath: string; private readonly emitLogEvent: (logLevel: LogLevel, message: string) => void; @@ -24,7 +25,7 @@ export class FlowAnalyzerApiClient { constructor( authService: FlowAnalyzerAuthService, timeoutMs: number, - apiVersion: string, + apiVersion: string | undefined, rulesPath: string, analyzePath: string, emitLogEvent: (logLevel: LogLevel, message: string) => void = () => {}, @@ -132,9 +133,20 @@ export class FlowAnalyzerApiClient { return SeverityLevel.Moderate; } + /** + * An explicitly configured api_version always wins. Otherwise use the org's version, but never one older than + * MIN_API_VERSION, since the CLI connection's version may predate the Flow Analyzer endpoints. + */ + private resolveApiVersion(): string { + if (this.apiVersion) { + return this.apiVersion; + } + const orgVersion = this.authService.getApiVersion(); + return orgVersion && parseFloat(orgVersion) > parseFloat(MIN_API_VERSION) ? orgVersion : MIN_API_VERSION; + } + private async requestJson(method: 'GET' | 'POST', resourcePath: string, body?: unknown): Promise { - // Always use the engine's configured version: the CLI connection's version may predate the Flow Analyzer endpoints. - const url = `${this.authService.getInstanceUrl()}/services/data/v${this.apiVersion}${resourcePath}`; + const url = `${this.authService.getInstanceUrl()}/services/data/v${this.resolveApiVersion()}${resourcePath}`; this.emitLogEvent(LogLevel.Fine, `Flow Analyzer ${method} ${url}`); const controller = new AbortController(); diff --git a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts index 69713016..5fca14e5 100644 --- a/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts +++ b/packages/code-analyzer-flowanalyzer-engine/src/services/FlowAnalyzerAuthService.ts @@ -51,4 +51,8 @@ export class FlowAnalyzerAuthService { getInstanceUrl(): string { return this.getConnection().instanceUrl; } + + getApiVersion(): string | undefined { + return this.getConnection().version || undefined; + } } diff --git a/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts index 6f00b36f..a382118d 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/api-client.test.ts @@ -11,9 +11,13 @@ describe('FlowAnalyzerApiClient', () => { getAccessToken: () => token, refreshAccessToken, getInstanceUrl: () => 'https://example.my.salesforce.com', - // Simulates an older CLI connection version; requests must still use the engine's configured version. - getConnection: () => ({ version: '60.0' }) + getApiVersion: () => orgApiVersion } as unknown as FlowAnalyzerAuthService; + let orgApiVersion: string | undefined; + + beforeEach(() => { + orgApiVersion = '68.0'; + }); const sampleFiles: CollectedFlowFile[] = [{ absolutePath: '/tmp/a.flow-meta.xml', @@ -21,11 +25,11 @@ describe('FlowAnalyzerApiClient', () => { content: '' }]; - function createClient(fetchImpl: typeof fetch): FlowAnalyzerApiClient { + function createClient(fetchImpl: typeof fetch, apiVersion?: string): FlowAnalyzerApiClient { return new FlowAnalyzerApiClient( authService, 1000, - '68.0', + apiVersion, '/flowbuilder/flow-analyzer/rules', '/flowbuilder/flow-analyzer/analysis/metadata-archive', jest.fn(), @@ -98,15 +102,33 @@ describe('FlowAnalyzerApiClient', () => { .rejects.toThrow(getMessage('CatalogMalformed')); }); - it('uses the configured api version rather than the connection version', async () => { + async function requestedUrl(apiVersion: string | undefined, orgVersion: string | undefined): Promise { + orgApiVersion = orgVersion; const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ rules: [] }) }); - await createClient(fetchImpl as unknown as typeof fetch).describeRules(); - expect(fetchImpl.mock.calls[0][0]).toEqual( - 'https://example.my.salesforce.com/services/data/v68.0/flowbuilder/flow-analyzer/rules'); + await createClient(fetchImpl as unknown as typeof fetch, apiVersion).describeRules(); + return fetchImpl.mock.calls[0][0]; + } + + it('uses the org api version when it is newer than the minimum and api_version is not configured', async () => { + expect(await requestedUrl(undefined, '69.0')).toEqual( + 'https://example.my.salesforce.com/services/data/v69.0/flowbuilder/flow-analyzer/rules'); + }); + + it('never goes below the minimum api version when the org connection is older', async () => { + expect(await requestedUrl(undefined, '60.0')).toContain('/services/data/v68.0/'); + }); + + it('uses the minimum api version when the org connection reports none', async () => { + expect(await requestedUrl(undefined, undefined)).toContain('/services/data/v68.0/'); + }); + + it('always honors an explicitly configured api_version', async () => { + expect(await requestedUrl('68.0', '69.0')).toContain('/services/data/v68.0/'); + expect(await requestedUrl('70.0', '60.0')).toContain('/services/data/v70.0/'); }); it('posts a multipart ZIP to the metadata-archive endpoint', async () => { diff --git a/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts index 61ef6ad7..5801de24 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/auth.test.ts @@ -26,6 +26,7 @@ describe('FlowAnalyzerAuthService', () => { expect(Org.create).toHaveBeenCalledWith({ aliasOrUsername: 'myorg' }); expect(authService.getInstanceUrl()).toEqual('https://test.salesforce.com'); expect(authService.getAccessToken()).toEqual('mock_access_token'); + expect(authService.getApiVersion()).toEqual('64.0'); }); it('does not fall back to the default org', async () => { @@ -46,4 +47,11 @@ describe('FlowAnalyzerAuthService', () => { await authService.initialize({ targetOrg: 'myorg' }); expect(() => authService.getAccessToken()).toThrow(getMessage('AccessTokenUnavailable')); }); + + it('reports no api version when the connection has none, leaving the choice to the API client', async () => { + const mockOrg = { getConnection: jest.fn().mockReturnValue({ ...mockConnection, version: undefined }) }; + (Org.create as jest.Mock).mockResolvedValue(mockOrg); + await authService.initialize({ targetOrg: 'myorg' }); + expect(authService.getApiVersion()).toBeUndefined(); + }); }); diff --git a/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts b/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts index 1300edce..18453afa 100644 --- a/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts +++ b/packages/code-analyzer-flowanalyzer-engine/test/plugin.test.ts @@ -59,6 +59,29 @@ describe('Tests for the FlowAnalyzerEnginePlugin', () => { expect(config.target_org).toBeUndefined(); }); + it('leaves api_version unset by default so the org version can be used', async () => { + const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org' + }, `engines.${ENGINE_NAME}`)); + expect(config.api_version).toBeUndefined(); + }); + + it('accepts an explicit api_version', async () => { + const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org', + api_version: '69.0' + }, `engines.${ENGINE_NAME}`)); + expect(config.api_version).toEqual('69.0'); + }); + + it('rejects a malformed api_version', async () => { + await expect(plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ + target_org: 'my-org', + api_version: 'v69' + }, `engines.${ENGINE_NAME}`))).rejects.toThrow( + getMessage('InvalidApiVersion', `engines.${ENGINE_NAME}.api_version`, 'v69')); + }); + it('trims target_org', async () => { const config = await plugin.createEngineConfig(ENGINE_NAME, new ConfigValueExtractor({ target_org: ' my-org '