Skip to content

feat: initialize cognitive complexity #174

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .swcrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://swc.rs/schema.json",
"sourceMaps": false,
"sourceMaps":false,
"module": {
"type": "es6",
"strictMode": true,
Expand Down
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ const config: Config = {
keepClassNames: true,
baseUrl: ".",
},
minify: false,
},
],
},
Expand Down
288 changes: 275 additions & 13 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"devDependencies": {
"@jest/types": "^29.6.3",
"@stryker-mutator/core": "^8.7.1",
"@stryker-mutator/jest-runner": "^8.7.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.14",
Expand All @@ -55,7 +56,6 @@
"vite-plugin-node-polyfills": "^0.23.0"
},
"dependencies": {
"@stryker-mutator/core": "^8.7.1",
"xmlbuilder2": "^3.1.1"
}
}
96 changes: 96 additions & 0 deletions src/main/libs/ExtractNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { FlowMetadata } from "../models/FlowMetadata";
import { FlowVariable } from "../models/FlowVariable";
import { FlowNode } from "../models/FlowNode";
import { FlowResource } from "../models/FlowResource";
import { Flow } from "../models/Flow";
import { FlowElement } from "../models/FlowElement";

const flowVariables = ["choices", "constants", "dynamicChoiceSets", "formulas", "variables"];
const flowResources = ["textTemplates", "stages"];
const flowMetadata = [
"description",
"apiVersion",
"processMetadataValues",
"processType",
"interviewLabel",
"label",
"status",
"runInMode",
"startElementReference",
"isTemplate",
"fullName",
"timeZoneSidKey",
"isAdditionalPermissionRequiredToRun",
"migratedFromWorkflowRuleName",
"triggerOrder",
"environments",
"segment",
];
const flowNodes = [
"actionCalls",
"apexPluginCalls",
"assignments",
"collectionProcessors",
"decisions",
"loops",
"orchestratedStages",
"recordCreates",
"recordDeletes",
"recordLookups",
"recordUpdates",
"recordRollbacks",
"screens",
"start",
"steps",
"subflows",
"waits",
"customErrors",
];

const extractNodes = (flow: Flow): FlowElement[] => {
const allNodes: FlowElement[] = [];
allNodes.push = function (...items: FlowElement[]): number {
const element = items[0];
flow.patchTree(element.name as string, element);
return Array.prototype.push.apply(this, items);
};
for (const nodeType in flow.xmldata) {
const data = flow.xmldata[nodeType];
if (flowMetadata.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowMetadata(nodeType, node));
}
} else {
allNodes.push(new FlowMetadata(nodeType, data));
}
} else if (flowVariables.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowVariable(node.name, nodeType, node));
}
} else {
allNodes.push(new FlowVariable(data.name, nodeType, data));
}
} else if (flowNodes.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowNode(node.name, nodeType, node));
}
} else {
allNodes.push(new FlowNode(data.name, nodeType, data));
}
} else if (flowResources.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowResource(node.name, nodeType, node));
}
} else {
allNodes.push(new FlowResource(data.name, nodeType, data));
}
}
}
return allNodes;
};

export { extractNodes };
2 changes: 1 addition & 1 deletion src/main/libs/ScanFlows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ParsedFlow } from "../models/ParsedFlow";

export function scan(
parsedFlows: ParsedFlow[],
ruleOptions?: core.IRulesConfig
ruleOptions?: Partial<core.IRulesConfig>
): core.ScanResult[] {
const flows: core.Flow[] = [];
for (const flow of parsedFlows) {
Expand Down
127 changes: 41 additions & 86 deletions src/main/models/Flow.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { FlowNode } from "./FlowNode";
import { FlowMetadata } from "./FlowMetadata";
import { FlowElement } from "./FlowElement";
import { FlowVariable } from "./FlowVariable";
import { TreeNode, HashTreeNode } from "./TreeNode";

import { extractNodes } from "../libs/ExtractNodes";

import * as p from "path";
import { FlowResource } from "./FlowResource";
import { XMLSerializedAsObject } from "xmlbuilder2/lib/interfaces";
import { create } from "xmlbuilder2";
import { FlowElementConnector } from "./FlowElementConnector";
import process from "process";

type CommonConnector = {
connectors: FlowElementConnector[];
};

export class Flow {
public label: string;
Expand All @@ -24,46 +31,7 @@ export class Flow {
public startReference;
public triggerOrder?: number;

private flowVariables = ["choices", "constants", "dynamicChoiceSets", "formulas", "variables"];
private flowResources = ["textTemplates", "stages"];
private flowMetadata = [
"description",
"apiVersion",
"processMetadataValues",
"processType",
"interviewLabel",
"label",
"status",
"runInMode",
"startElementReference",
"isTemplate",
"fullName",
"timeZoneSidKey",
"isAdditionalPermissionRequiredToRun",
"migratedFromWorkflowRuleName",
"triggerOrder",
"environments",
"segment",
];
private flowNodes = [
"actionCalls",
"apexPluginCalls",
"assignments",
"collectionProcessors",
"decisions",
"loops",
"orchestratedStages",
"recordCreates",
"recordDeletes",
"recordLookups",
"recordUpdates",
"recordRollbacks",
"screens",
"start",
"steps",
"subflows",
"waits",
];
private flowElementConnection: HashTreeNode<FlowElement> = { start: {} };

constructor(path?: string, data?: unknown);
constructor(path: string, data?: unknown) {
Expand All @@ -84,7 +52,7 @@ export class Flow {
}
}

public preProcessNodes() {
private preProcessNodes() {
this.label = this.xmldata.label;
this.interviewLabel = this.xmldata.interviewLabel;
this.processType = this.xmldata.processType;
Expand All @@ -94,48 +62,7 @@ export class Flow {
this.status = this.xmldata.status;
this.type = this.xmldata.processType;
this.triggerOrder = this.xmldata.triggerOrder;
const allNodes: (FlowVariable | FlowNode | FlowMetadata)[] = [];
for (const nodeType in this.xmldata) {
// skip xmlns url
// if (nodeType == "@xmlns") {
// continue;
// }
const data = this.xmldata[nodeType];
if (this.flowMetadata.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowMetadata(nodeType, node));
}
} else {
allNodes.push(new FlowMetadata(nodeType, data));
}
} else if (this.flowVariables.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowVariable(node.name, nodeType, node));
}
} else {
allNodes.push(new FlowVariable(data.name, nodeType, data));
}
} else if (this.flowNodes.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowNode(node.name, nodeType, node));
}
} else {
allNodes.push(new FlowNode(data.name, nodeType, data));
}
} else if (this.flowResources.includes(nodeType)) {
if (Array.isArray(data)) {
for (const node of data) {
allNodes.push(new FlowResource(node.name, nodeType, node));
}
} else {
allNodes.push(new FlowResource(data.name, nodeType, data));
}
}
}
this.elements = allNodes;
this.elements = extractNodes(this);
this.startReference = this.findStart();
}

Expand All @@ -156,9 +83,37 @@ export class Flow {
});
start = startElement.connectors[0]["reference"];
}
const startNode: Partial<FlowElement> = {
name: start,
connectors: [{ reference: start }],
};
this.patchTree("start", startNode as FlowElement);
return start;
}

public patchTree(key: string, elem: FlowElement): void {
const { COLLECT_CHILDREN: collectChildren } = process.env;
if (!collectChildren) {
return;
}
const children: Partial<TreeNode<FlowElement>>[] = [];
const connectorElement = elem as CommonConnector;
if (connectorElement.connectors) {
for (const connector of connectorElement.connectors) {
if (children.find((child) => child?.value?.name === connector.reference)) {
continue;
}
children.push({
value: { name: connector.reference } as unknown as FlowElement,
});
}
}
this.flowElementConnection[key] = {
value: elem,
children: children as TreeNode<FlowElement>[],
};
}

public toXMLString(): string {
try {
return this.generateDoc();
Expand Down
2 changes: 1 addition & 1 deletion src/main/models/FlowType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class FlowType {
"LoyaltyManagementFlow",
];

public static allTypes = function () {
public static allTypes = () => {
return [...this.backEndTypes, ...this.visualTypes, ...this.surveyTypes];
};
}
37 changes: 37 additions & 0 deletions src/main/models/TreeNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { FlowElement } from "./FlowElement";

export type TreeNode<T = unknown> = {
value: T;
children: TreeNode<T>[];
};

export type HashTreeNode<T = unknown> = Record<string, Partial<TreeNode<T>>>;

export function toTreeStackPath(tree: HashTreeNode<FlowElement>): string[] {
const callStack: string[] = [];
const stack: { component: TreeNode<FlowElement>; path: string }[] = [
{
component: tree[tree.start.children?.[0].value.name as string] as TreeNode<FlowElement>,
path: "start",
},
];

while (stack.length > 0) {
const { component, path } = stack.pop()!;
const visitedComponent = component.value.name as string;
const newPath = `${path} -> ${visitedComponent}`;

if (!component?.children || component.children.length === 0 || newPath.length > 1_000) {
callStack.push(newPath);
} else {
for (const child of component.children) {
stack.push({
component: tree[child.value.name as string] as TreeNode<FlowElement>,
path: newPath,
});
}
}
}

return callStack.filter((stack) => stack);
}
46 changes: 46 additions & 0 deletions src/main/rules/CognitiveComplexity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
Flow,
IRuleDefinition,
RuleResult,
FlowType,
ResultDetails,
FlowAttribute,
} from "../internals/internals";
import { RuleCommon } from "../models/RuleCommon";

import { toTreeStackPath } from "../models/TreeNode";

export class CognitiveComplexity extends RuleCommon implements IRuleDefinition {
constructor() {
super({
name: "CognitiveComplexity",
label: "Cognitive Complexity",
description: `
Much like code, a flows can be hard to understand and maintain.
A flow's cognitive complexity is based on a few simple rules:
1) Flow is considered more complex for each "break in the linear program flow"
2) Flow is considered more complex when "program flow breaking structures are nested"
`,
supportedTypes: [...FlowType.backEndTypes],
docRefs: [],
isConfigurable: true,
autoFixable: false,
});
}
private defaultMaxCognitiveCount: number = 5;

public execute(flow: Flow, ruleOptions?: { max: number }): RuleResult {
const results: ResultDetails[] = [];
const maxCognitiveCount: number = ruleOptions?.max ?? this.defaultMaxCognitiveCount;
const stackPath: string[] = toTreeStackPath(flow["flowElementConnection"]);
const flowCognitiveCount: number = stackPath.length;
if (flowCognitiveCount > maxCognitiveCount) {
results.push(
new ResultDetails(
new FlowAttribute(this.name, "Flow Overall Logic", "Too many decision branches")
)
);
}
return new RuleResult(this, results);
}
}
Loading