diff --git a/jest.config.ts b/jest.config.ts index 08186149..7dcd13f5 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -172,30 +172,8 @@ const config: Config = { "^.+\\.(t|j)sx?$": [ "@swc/jest", { - $schema: "https://swc.rs/schema.json", sourceMaps: "inline", - module: { - type: "es6", - strictMode: true, - noInterop: false, - resolveFully: false, - }, - jsc: { - externalHelpers: false, - target: "es2015", - parser: { - syntax: "typescript", - tsx: true, - decorators: true, - dynamicImport: true, - }, - transform: { - legacyDecorator: true, - decoratorMetadata: false, - }, - keepClassNames: true, - baseUrl: ".", - }, + minify: false, }, ], }, diff --git a/readme.md b/readme.md index 77f1ddd2..2b2a99a5 100644 --- a/readme.md +++ b/readme.md @@ -32,6 +32,7 @@ _An Extensible Rule Engine capable of conducting static analysis on the metadata | **Unsafe Running Context** ([`UnsafeRunningContext`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/UnsafeRunningContext.ts)) | This flow is configured to run in System Mode without Sharing. This system context grants all running users the permission to view and edit all data in your org. Running a flow in System Mode without Sharing can lead to unsafe data access. | | **Same Record Field Updates** ([`SameRecordFieldUpdates`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/SameRecordFieldUpdates.ts)) | Much like triggers, before contexts can update the same record by accessing the trigger variables `$Record` without needing to invoke a DML. | | **Trigger Order** ([`TriggerOrder`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/TriggerOrder.ts)) | Guarantee your flow execution order with the Trigger Order property introduced in Spring '22 | +| **Cyclomatic Complexity** ([`CyclomaticComplexity`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/TriggerOrder.ts)) | The number of loops and decision rules, plus the number of decisions. Use a combination of 1) subflows and 2) breaking flows into multiple concise trigger ordered flows, to reduce the cyclomatic complexity within a single flow, ensuring maintainability and simplicity. | ## Core Functions diff --git a/src/main/libs/ParseFlows.ts b/src/main/libs/ParseFlows.ts index 8671219c..02950a08 100644 --- a/src/main/libs/ParseFlows.ts +++ b/src/main/libs/ParseFlows.ts @@ -4,7 +4,7 @@ import fs from "fs"; import { convert } from "xmlbuilder2"; import { ParsedFlow } from "../models/ParsedFlow"; -export async function ParseFlows(selectedUris: string[]): Promise { +export async function parse(selectedUris: string[]): Promise { const parseResults: ParsedFlow[] = []; for (const uri of selectedUris) { try { @@ -19,7 +19,3 @@ export async function ParseFlows(selectedUris: string[]): Promise } return parseResults; } - -export function parse(selectedUris: string[]): Promise { - return ParseFlows(selectedUris); -} diff --git a/src/main/models/FlowElement.ts b/src/main/models/FlowElement.ts index 13cf07f1..7436cea2 100644 --- a/src/main/models/FlowElement.ts +++ b/src/main/models/FlowElement.ts @@ -1,7 +1,7 @@ export class FlowElement { public subtype: string; public metaType: string; - public element: string | object = {}; + public element: string | object[] | object = {}; public connectors?: object[]; public name?: string; public locationX?: string; diff --git a/src/main/rules/CyclomaticComplexity.ts b/src/main/rules/CyclomaticComplexity.ts new file mode 100644 index 00000000..6dd7968e --- /dev/null +++ b/src/main/rules/CyclomaticComplexity.ts @@ -0,0 +1,66 @@ +import { RuleCommon } from "../models/RuleCommon"; +import * as core from "../internals/internals"; + +export class CyclomaticComplexity extends RuleCommon implements core.IRuleDefinition { + constructor() { + super( + { + name: "CyclomaticComplexity", + label: "Cyclomatic Complexity", + description: `The number of loops and decision rules, plus the number of decisions. + Use a combination of 1) subflows and 2) breaking flows into multiple concise trigger ordered flows, + to reduce the cyclomatic complexity within a single flow, ensuring maintainability and simplicity.`, + supportedTypes: core.FlowType.backEndTypes, + docRefs: [ + { + label: `Cyclomatic complexity is a software metric used to indicate the complexity of a program. + It is a quantitative measure of the number of linearly independent paths through a program's source code.`, + path: "https://en.wikipedia.org/wiki/Cyclomatic_complexity", + }, + ], + isConfigurable: true, + autoFixable: false, + }, + { severity: "note" } + ); + } + + private defaultThreshold: number = 25; + + private cyclomaticComplexityUnit: number = 0; + + public execute(flow: core.Flow, options?: { threshold: number }): core.RuleResult { + // Set Threshold + const threshold = options?.threshold || this.defaultThreshold; + + // Calculate Cyclomatic Complexity based on the number of decision rules and loops, adding the number of decisions plus 1. + let cyclomaticComplexity = 1; + + const flowDecisions = flow?.elements?.filter( + (node) => node.subtype === "decisions" + ) as core.FlowElement[]; + const flowLoops = flow?.elements?.filter((node) => node.subtype === "loops"); + + for (const decision of flowDecisions || []) { + const rules = decision.element["rules"]; + if (Array.isArray(rules)) { + cyclomaticComplexity += rules.length + 1; + } else { + cyclomaticComplexity += 1; + } + } + cyclomaticComplexity += flowLoops?.length ?? 0; + + this.cyclomaticComplexityUnit = cyclomaticComplexity; // for unit testing + + const results: core.ResultDetails[] = []; + if (cyclomaticComplexity > threshold) { + results.push( + new core.ResultDetails( + new core.FlowAttribute(`${cyclomaticComplexity}`, "CyclomaticComplexity", `>${threshold}`) + ) + ); + } + return new core.RuleResult(this, results); + } +} diff --git a/src/main/store/DefaultRuleStore.ts b/src/main/store/DefaultRuleStore.ts index 5043d591..d4126f61 100644 --- a/src/main/store/DefaultRuleStore.ts +++ b/src/main/store/DefaultRuleStore.ts @@ -1,6 +1,7 @@ import { APIVersion } from "../rules/APIVersion"; import { AutoLayout } from "../rules/AutoLayout"; import { CopyAPIName } from "../rules/CopyAPIName"; +import { CyclomaticComplexity } from "../rules/CyclomaticComplexity"; import { DMLStatementInLoop } from "../rules/DMLStatementInLoop"; import { DuplicateDMLOperation } from "../rules/DuplicateDMLOperation"; import { FlowDescription } from "../rules/FlowDescription"; @@ -20,6 +21,7 @@ export const DefaultRuleStore: object = { APIVersion, AutoLayout, CopyAPIName, + CyclomaticComplexity, DMLStatementInLoop, DuplicateDMLOperation, FlowDescription, diff --git a/tests/CyclomaticComplexity.test.ts b/tests/CyclomaticComplexity.test.ts new file mode 100644 index 00000000..f0fdcafd --- /dev/null +++ b/tests/CyclomaticComplexity.test.ts @@ -0,0 +1,123 @@ +import * as path from "path"; +import { describe, it, expect } from "@jest/globals"; + +import * as core from "../src"; +import { CyclomaticComplexity } from "../src/main/rules/CyclomaticComplexity"; + +describe("CyclomaticComplexity ", () => { + const example_uri = path.join(__dirname, "./xmlfiles/Cyclomatic_Complexity.flow-meta.xml"); + const other_uri = path.join(__dirname, "./xmlfiles/SOQL_Query_In_A_Loop.flow-meta.xml"); + const defaultConfig = { + rules: { + CyclomaticComplexity: { + severity: "error", + }, + }, + }; + + it("should have a result when there are more than 25 decision options", async () => { + const flows = await core.parse([example_uri]); + debugger; + const results: core.ScanResult[] = core.scan(flows, defaultConfig); + const occurringResults = results[0].ruleResults.filter((rule) => rule.occurs); + expect(occurringResults).toHaveLength(1); + expect(occurringResults[0].ruleName).toBe("CyclomaticComplexity"); + }); + + it("should have no result when value is below threshold", async () => { + const flows = await core.parse([other_uri]); + + const results: core.ScanResult[] = core.scan(flows, defaultConfig); + const occurringResults = results[0].ruleResults.filter((rule) => rule.occurs); + expect(occurringResults).toHaveLength(0); + }); + + it("should have a result when value surpasses a configured threshold", async () => { + const flows = await core.parse([other_uri]); + const ruleConfig = { + rules: { + CyclomaticComplexity: { + threshold: 1, + severity: "error", + }, + }, + }; + + const results: core.ScanResult[] = core.scan(flows, ruleConfig); + const occurringResults = results[0].ruleResults.filter((rule) => rule.occurs); + expect(occurringResults).toHaveLength(1); + expect(occurringResults[0].ruleName).toBe("CyclomaticComplexity"); + }); + + it("should correctly count the number of decisions and underlying rules one level", () => { + const sut = new CyclomaticComplexity(); + const raw = { + elements: [ + { + subtype: "decisions", + element: { + rules: [{}, {}, {}], + }, + }, + ], + } as Partial; + const given = raw as core.Flow; + sut.execute(given); + expect(sut["cyclomaticComplexityUnit"]).toBe(5); + }); + + it("should correctly count the number of decisions and underlying rules multi level", () => { + const sut = new CyclomaticComplexity(); + const raw = { + elements: [ + { + subtype: "decisions", + element: { + rules: [{}, {}, {}], + }, + }, + { subtype: "decisions", element: { rules: [{}] } }, + ], + } as Partial; + const given = raw as core.Flow; + sut.execute(given); + expect(sut["cyclomaticComplexityUnit"]).toBe(7); + }); + + it("should not throw an exception when theres no elements at all", () => { + const sut = new CyclomaticComplexity(); + const raw = { + elements: [], + } as Partial; + const given = raw as core.Flow; + expect(() => { + sut.execute(given); + }).not.toThrow(); + }); + + it("should not throw an exception when element isn't present", () => { + const sut = new CyclomaticComplexity(); + const raw = {} as Partial; + const given = raw as core.Flow; + expect(() => { + sut.execute(given); + }).not.toThrow(); + }); + + it("should correctly count the number of loops", () => { + const sut = new CyclomaticComplexity(); + const raw = { + elements: [ + { + subtype: "loops", + }, + { + subtype: "loops", + }, + ], + } as Partial; + const given = raw as core.Flow; + sut.execute(given); + expect(sut["cyclomaticComplexityUnit"]).toBe(3); + }); +}); diff --git a/tests/UnconnectedElement.test.ts b/tests/UnconnectedElement.test.ts index 78bdd016..1cef55b0 100644 --- a/tests/UnconnectedElement.test.ts +++ b/tests/UnconnectedElement.test.ts @@ -1,7 +1,7 @@ import * as core from "../src"; import * as path from "path"; -import { ParseFlows } from "../src/main/libs/ParseFlows"; +import { parse } from "../src/main/libs/ParseFlows"; import { ParsedFlow } from "../src/main/models/ParsedFlow"; import { UnconnectedElement } from "../src/main/rules/UnconnectedElement"; @@ -16,7 +16,7 @@ describe("UnconnectedElement", () => { __dirname, "./xmlfiles/Unconnected_Element.flow-meta.xml" ); - const parsed: ParsedFlow = (await ParseFlows([connectedElementTestFile])).pop() as ParsedFlow; + const parsed: ParsedFlow = (await parse([connectedElementTestFile])).pop() as ParsedFlow; const ruleResult: core.RuleResult = unconnectedElementRule.execute(parsed.flow as core.Flow); expect(ruleResult.occurs).toBe(true); expect(ruleResult.details).not.toHaveLength(0); @@ -30,7 +30,7 @@ describe("UnconnectedElement", () => { __dirname, "./xmlfiles/Unconnected_Element_Async.flow-meta.xml" ); - const parsed: ParsedFlow = (await ParseFlows([connectedElementTestFile])).pop() as ParsedFlow; + const parsed: ParsedFlow = (await parse([connectedElementTestFile])).pop() as ParsedFlow; const ruleResult: core.RuleResult = unconnectedElementRule.execute(parsed.flow as core.Flow); expect(ruleResult.occurs).toBe(true); ruleResult.details.forEach((ruleDetail) => { diff --git a/tests/UnsafeRunningContext.test.ts b/tests/UnsafeRunningContext.test.ts index bac0bee9..3ffe4d20 100644 --- a/tests/UnsafeRunningContext.test.ts +++ b/tests/UnsafeRunningContext.test.ts @@ -1,7 +1,7 @@ import * as core from "../src"; import * as path from "path"; -import { ParseFlows } from "../src/main/libs/ParseFlows"; +import { parse } from "../src/main/libs/ParseFlows"; import { ParsedFlow } from "../src/main/models/ParsedFlow"; import { describe, it, expect } from "@jest/globals"; @@ -15,7 +15,7 @@ describe("UnsafeRunningContext", () => { __dirname, "./xmlfiles/Unsafe_Running_Context.flow-meta.xml" ); - const parsed: ParsedFlow = (await ParseFlows([unsafeContextTestFile])).pop() as ParsedFlow; + const parsed: ParsedFlow = (await parse([unsafeContextTestFile])).pop() as ParsedFlow; const ruleResult: core.RuleResult = unsafeRunningContext.execute(parsed.flow as core.Flow); expect(ruleResult.occurs).toBe(true); expect(ruleResult.details).not.toHaveLength(0); @@ -27,7 +27,7 @@ describe("UnsafeRunningContext", () => { __dirname, "./xmlfiles/Unsafe_Running_Context_WithSharing.flow-meta.xml" ); - const parsed: ParsedFlow = (await ParseFlows([unsafeContextTestFile])).pop() as ParsedFlow; + const parsed: ParsedFlow = (await parse([unsafeContextTestFile])).pop() as ParsedFlow; const ruleResult: core.RuleResult = unsafeRunningContext.execute(parsed.flow as core.Flow); expect(ruleResult.occurs).toBe(false); expect(ruleResult.details).toHaveLength(0); @@ -38,7 +38,7 @@ describe("UnsafeRunningContext", () => { __dirname, "./xmlfiles/Unsafe_Running_Context_Default.flow-meta.xml" ); - const parsed: ParsedFlow = (await ParseFlows([unsafeContextTestFile])).pop() as ParsedFlow; + const parsed: ParsedFlow = (await parse([unsafeContextTestFile])).pop() as ParsedFlow; const ruleResult: core.RuleResult = unsafeRunningContext.execute(parsed.flow as core.Flow); expect(ruleResult.occurs).toBe(false); expect(ruleResult.details).toHaveLength(0); diff --git a/tests/xmlfiles/Cyclomatic_Complexity.flow-meta.xml b/tests/xmlfiles/Cyclomatic_Complexity.flow-meta.xml new file mode 100644 index 00000000..f42363a2 --- /dev/null +++ b/tests/xmlfiles/Cyclomatic_Complexity.flow-meta.xml @@ -0,0 +1,653 @@ + + + + createtesttask + + 2822 + 434 + FeedItem.NewTaskFromFeedItem + quickAction + CurrentTransaction + + contextId + + $Flow.CurrentRecord + + + FeedItem.NewTaskFromFeedItem + 1 + + 60.0 + + assignvalue1 + + 50 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue10 + + 2426 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue11 + + 2690 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue12 + + 5594 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue2 + + 314 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue3 + + 578 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue4 + + 842 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue5 + + 1106 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue6 + + 1370 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue7 + + 1634 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue8 + + 1898 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + assignvalue9 + + 2162 + 242 + + testvar + Assign + + 1.0 + + + + createtesttask + + + + Example decision above default threshold + DecideOutcome + + 2822 + 134 + + assignvalue12 + + Default Outcome + + outcome1 + and + + $Flow.CurrentRecord + Contains + + a + + + + assignvalue1 + + + + + outcome2 + and + + $Flow.CurrentRecord + Contains + + b + + + + assignvalue2 + + + + + outcome3 + and + + $Flow.CurrentRecord + Contains + + c + + + + assignvalue3 + + + + + outcome4 + and + + $Flow.CurrentRecord + Contains + + d + + + + assignvalue4 + + + + + outcome5 + and + + $Flow.CurrentRecord + Contains + + e + + + + assignvalue5 + + + + + outcome6 + and + + $Flow.CurrentRecord + Contains + + f + + + + assignvalue6 + + + + + outcome7 + and + + $Flow.CurrentRecord + Contains + + g + + + + assignvalue7 + + + + + outcome8 + and + + $Flow.CurrentRecord + Contains + + h + + + + assignvalue8 + + + + + outcome9 + and + + $Flow.CurrentRecord + Contains + + i + + + + assignvalue9 + + + + + outcome10 + and + + $Flow.CurrentRecord + Contains + + j + + + + assignvalue10 + + + + + outcome11 + and + + $Flow.CurrentRecord + Contains + + k + + + + assignvalue11 + + + + + outcome12 + and + + testvar + EqualTo + + 12.0 + + + + createtesttask + + + + + outcome13 + and + + testvar + EqualTo + + 13.0 + + + + createtesttask + + + + + outcome14 + and + + testvar + EqualTo + + 14.0 + + + + createtesttask + + + + + outcome15 + and + + testvar + EqualTo + + 15.0 + + + + createtesttask + + + + + outcome16 + and + + testvar + EqualTo + + 16.0 + + + + createtesttask + + + + + outcome17 + and + + testvar + EqualTo + + 17.0 + + + + createtesttask + + + + + outcome18 + and + + testvar + EqualTo + + 18.0 + + + + createtesttask + + + + + outcome19 + and + + testvar + EqualTo + + 19.0 + + + + createtesttask + + + + + outcome20 + and + + testvar + EqualTo + + 20.0 + + + + createtesttask + + + + + outcome21 + and + + testvar + EqualTo + + 21.0 + + + + createtesttask + + + + + outcome22 + and + + testvar + EqualTo + + 22.0 + + + + createtesttask + + + + + outcome23 + and + + testvar + EqualTo + + 23.0 + + + + createtesttask + + + + + outcome24 + and + + testvar + EqualTo + + 24.0 + + + + createtesttask + + + + + outcome25 + and + + testvar + EqualTo + + 25.0 + + + + createtesttask + + + + + outcome26 + and + + testvar + EqualTo + + 26.0 + + + + createtesttask + + + + + This flow demonstrates a violation of the rule 'CyclomaticComplexity' + Default + Cyclomatic {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + 2696 + 0 + + DecideOutcome + + + Active + + testvar + Number + false + false + false + 2 + +