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/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..c0599c4f --- /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: 'get_Account', + elementType: 'recordLookups' + }, + { + ruleName: 'UnusedVariable', + message: 'should be filtered if not selected - still known', + relativeFilePath: 'Sample.flow-meta.xml', + 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)); + 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: '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)); + 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: '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)); + 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: 'recordLookups' + }]); + 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..05d90bb0 --- /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, 5000, 10000); + 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('get_Account'); + }); + + 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, 5000, 10000); + 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..16a0f6fd --- /dev/null +++ b/packages/code-analyzer-flowanalyzer-engine/test/test-data/example-workspaces/contains-one-flow/Sample.flow-meta.xml @@ -0,0 +1,44 @@ + + + 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/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-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" + ] + }