Skip to content

Commit

Permalink
Add rule execution code (#31)
Browse files Browse the repository at this point in the history
This commit adds code which will be used to run rules against a
particular project. This first involves looking at the dependencies
between the rules to determine the priority and order in which they
should be run, then representing the hierarchy as a tree structure.
After that, the tree is merely traversed.
  • Loading branch information
mcmire authored Nov 22, 2023
1 parent eac0b06 commit 5df667b
Show file tree
Hide file tree
Showing 8 changed files with 727 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
},
"dependencies": {
"@metamask/utils": "^8.2.0",
"dependency-graph": "^0.11.0",
"execa": "^5.1.1"
},
"devDependencies": {
Expand Down
136 changes: 136 additions & 0 deletions src/build-rule-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as dependencyGraphModule from 'dependency-graph';

import { buildRuleTree } from './build-rule-tree';
import type { Rule } from './execute-rules';

jest.mock('dependency-graph', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
...jest.requireActual('dependency-graph'),
};
});

describe('buildRuleTree', () => {
it('builds a nested data structure that starts with the rules that have no dependencies and navigates through their dependents recursively', () => {
const rule1: Rule = {
name: 'rule-1',
description: 'Description for rule 1',
dependencies: ['rule-2'],
execute: async () => {
return {
passed: true,
};
},
};
const rule2: Rule = {
name: 'rule-2',
description: 'Description for rule 2',
dependencies: ['rule-3'],
execute: async () => {
return {
passed: true,
};
},
};
const rule3: Rule = {
name: 'rule-3',
description: 'Description for rule 3',
dependencies: [],
execute: async () => {
return {
passed: true,
};
},
};
const rules = [rule1, rule2, rule3];

const ruleTree = buildRuleTree(rules);

expect(ruleTree).toStrictEqual({
children: [
{
rule: rule3,
children: [
{
rule: rule2,
children: [
{
rule: rule1,
children: [],
},
],
},
],
},
],
});
});

it('reinterprets a dependency cycle error from dep-graph if given a set of rules where a dependency cycle is present', () => {
const rule1: Rule = {
name: 'rule-1',
description: 'Description for rule 1',
dependencies: ['rule-2'],
execute: async () => {
return {
passed: false,
failures: [{ message: 'Oops' }],
};
},
};
const rule2: Rule = {
name: 'rule-2',
description: 'Description for rule 2',
dependencies: ['rule-3'],
execute: async () => {
return {
passed: true,
};
},
};
const rule3: Rule = {
name: 'rule-3',
description: 'Description for rule 3',
dependencies: ['rule-1'],
execute: async () => {
return {
passed: true,
};
},
};
const rules = [rule1, rule2, rule3];

expect(() => buildRuleTree(rules)).toThrow(
new Error(
`
Could not build rule tree as some rules depend on each other circularly:
- Rule "rule-1" depends on...
- Rule "rule-2", which depends on...
- Rule "rule-3", which depends on...
- Rule "rule-1" again (creating the cycle)
`.trim(),
),
);
});

it('re-throws any unknown if given a set of rules where a dependency cycle is present', () => {
const error = new Error('oops');
const depGraph = new dependencyGraphModule.DepGraph();
jest.spyOn(depGraph, 'overallOrder').mockImplementation(() => {
throw error;
});
jest.spyOn(dependencyGraphModule, 'DepGraph').mockReturnValue(depGraph);

expect(() => buildRuleTree([])).toThrow(error);
});

it('returns an empty root node if given no rules', () => {
const ruleTree = buildRuleTree([]);

expect(ruleTree).toStrictEqual({
children: [],
});
});
});
142 changes: 142 additions & 0 deletions src/build-rule-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { getErrorMessage } from '@metamask/utils/node';
import { DepGraph } from 'dependency-graph';

import type { Rule } from './execute-rules';
import { createModuleLogger, projectLogger } from './logging-utils';
import { indent } from './misc-utils';

const log = createModuleLogger(projectLogger, 'build-rule-tree');

/**
* A rule in the rule tree.
*/
export type RuleNode = {
rule: Rule;
children: RuleNode[];
};

/**
* The "bottom" of the rule tree, as it were. Really here just to satisfy the
* definition of a tree (which can't have more than one trunk).
*/
type RootRuleNode = {
children: RuleNode[];
};

/**
* Some rules are dependent on other rules to execute. For instance, if a rule
* is checking for a property within `tsconfig.json`, another rule that checks
* for the existence of `tsconfig.json` may need to be executed first. To
* determine the execution priority, we need to create a tree structure. The
* root of this tree is a dummy node whose children are all of the rules that do
* not depend on any other rules to execute. Some of these nodes may have
* children, which are the dependents of the rules represented by those nodes.
*
* @param rules - The rules to rearrange into a tree.
* @returns The rule tree.
*/
export function buildRuleTree(rules: readonly Rule[]): RootRuleNode {
const graph = new DepGraph<Rule>();

// Add all of the rules to the graph first so that they are available
rules.forEach((rule) => {
log(`Adding to graph: ${rule.name}`);
graph.addNode(rule.name, rule);
});

// Now we specify the connections between nodes
rules.forEach((rule) => {
rule.dependencies.forEach((dependencyName) => {
log(`Adding connection to graph: ${rule.name} -> ${dependencyName}`);
graph.addDependency(rule.name, dependencyName);
});
});

checkForDependencyCycle(graph);

const nodesWithoutDependencies = graph.overallOrder(true);
const children = buildRuleNodes(graph, nodesWithoutDependencies);

return { children };
}

/**
* The `dependency-graph` package will throw an error if it detects a dependency
* cycle in the graph (i.e., a node that depends on another node, which depends
* on the first node). It is impossible to turn a circular graph into a tree,
* as it would take forever (literally) to iterate through it. We take advantage
* of this to look for dependency cycles in the graph we've build from the rules
* and throw a similar error.
*
* @param graph - The graph made up of rules.
* @throws If a dependency cycle is present.
*/
function checkForDependencyCycle(graph: DepGraph<Rule>): void {
try {
graph.overallOrder();
} catch (error) {
const message = getErrorMessage(error);
const match = /^Dependency Cycle Found: (.+)$/u.exec(message);

if (match?.[1]) {
const nodesInCycle = match[1].split(' -> ');
const lines = [
'Could not build rule tree as some rules depend on each other circularly:',
'',
...nodesInCycle.map((node, i) => {
let line = `- Rule "${node}"`;
if (i === 0) {
line += ' depends on...';
} else if (i === nodesInCycle.length - 1) {
line += ' again (creating the cycle)';
} else {
line += ', which depends on...';
}
return indent(line, i);
}),
];
throw new Error(lines.join('\n'));
} else {
throw error;
}
}
}

/**
* Converts a series of node in the rule _graph_ into nodes into the rule
* _tree_. This function is called two ways: once when first building the tree
* for the rules that don't depend on any other rules, and then each time a
* rule's dependents are seen.
*
* @param graph - The rule graph.
* @param nodeNames - The names of rules in the graph to convert.
* @returns The built rule nodes.
* @see {@link buildRuleNode}
*/
function buildRuleNodes(
graph: DepGraph<Rule>,
nodeNames: string[],
): RuleNode[] {
return nodeNames.map((nodeName) => buildRuleNode(graph, nodeName));
}

/**
* Converts a node in the rule _graph_ into a node into the rule _tree_. As the
* nodes of the graph and the connections of the graph are kept separately, this
* function essentially combines them by nesting the rule's dependents under the
* rule itself. This function is recursive via `buildRuleNodes`, as doing so
* means that all of the dependents for a rule get their own node in the rule
* tree too.
*
* @param graph - The rule graph.
* @param nodeName - The name of a rule in the graph.
* @returns The built rule node.
*/
function buildRuleNode(graph: DepGraph<Rule>, nodeName: string): RuleNode {
const rule = graph.getNodeData(nodeName);
const dependents = graph.directDependentsOf(nodeName);
return {
rule,
children: buildRuleNodes(graph, dependents),
};
}
Loading

0 comments on commit 5df667b

Please sign in to comment.