Skip to content
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

feat: halstead complexity printer #1878

Merged
merged 16 commits into from
Sep 15, 2023
Merged
2 changes: 1 addition & 1 deletion scripts/ci_test_printers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
cd tests/e2e/solc_parsing/test_data/compile/ || exit

# Do not test the evm printer,as it needs a refactoring
ALL_PRINTERS="cfg,constructor-calls,contract-summary,data-dependency,echidna,function-id,function-summary,modifiers,call-graph,human-summary,inheritance,inheritance-graph,loc,slithir,slithir-ssa,vars-and-auth,require,variable-order,declaration"
ALL_PRINTERS="cfg,constructor-calls,contract-summary,data-dependency,echidna,function-id,function-summary,modifiers,call-graph,halstead,human-summary,inheritance,inheritance-graph,loc,slithir,slithir-ssa,vars-and-auth,require,variable-order,declaration"

# Only test 0.5.17 to limit test time
for file in *0.5.17-compact.zip; do
Expand Down
1 change: 1 addition & 0 deletions slither/printers/all_printers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .summary.slithir import PrinterSlithIR
from .summary.slithir_ssa import PrinterSlithIRSSA
from .summary.human_summary import PrinterHumanSummary
from .summary.halstead import Halstead
from .functions.cfg import CFG
from .summary.function_ids import FunctionIds
from .summary.variable_order import VariableOrder
Expand Down
48 changes: 48 additions & 0 deletions slither/printers/summary/halstead.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""
Halstead complexity metrics
https://en.wikipedia.org/wiki/Halstead_complexity_measures

12 metrics based on the number of unique operators and operands:

Core metrics:
n1 = the number of distinct operators
n2 = the number of distinct operands
N1 = the total number of operators
N2 = the total number of operands

Extended metrics1:
n = n1 + n2 # Program vocabulary
N = N1 + N2 # Program length
S = n1 * log2(n1) + n2 * log2(n2) # Estimated program length
V = N * log2(n) # Volume

Extended metrics2:
D = (n1 / 2) * (N2 / n2) # Difficulty
E = D * V # Effort
T = E / 18 seconds # Time required to program
B = (E^(2/3)) / 3000 # Number of delivered bugs

"""
from slither.printers.abstract_printer import AbstractPrinter
from slither.utils.halstead import HalsteadMetrics


class Halstead(AbstractPrinter):
ARGUMENT = "halstead"
HELP = "Computes the Halstead complexity metrics for each contract"

WIKI = "https://github.com/trailofbits/slither/wiki/Printer-documentation#halstead"

def output(self, _filename):
if len(self.contracts) == 0:
return self.generate_output("No contract found")

halstead = HalsteadMetrics(self.contracts)

res = self.generate_output(halstead.full_txt)
res.add_pretty_table(halstead.core.pretty_table, halstead.core.title)
res.add_pretty_table(halstead.extended1.pretty_table, halstead.extended1.title)
res.add_pretty_table(halstead.extended2.pretty_table, halstead.extended2.title)
self.info(halstead.full_txt)

return res
219 changes: 219 additions & 0 deletions slither/utils/halstead.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""
Halstead complexity metrics
https://en.wikipedia.org/wiki/Halstead_complexity_measures

12 metrics based on the number of unique operators and operands:

Core metrics:
n1 = the number of distinct operators
n2 = the number of distinct operands
N1 = the total number of operators
N2 = the total number of operands

Extended metrics1:
n = n1 + n2 # Program vocabulary
N = N1 + N2 # Program length
S = n1 * log2(n1) + n2 * log2(n2) # Estimated program length
V = N * log2(n) # Volume

Extended metrics2:
D = (n1 / 2) * (N2 / n2) # Difficulty
E = D * V # Effort
T = E / 18 seconds # Time required to program
B = (E^(2/3)) / 3000 # Number of delivered bugs


"""
import math
from dataclasses import dataclass, field
from typing import Tuple, List, Dict
from collections import OrderedDict
from slither.core.declarations import Contract
from slither.slithir.variables.temporary import TemporaryVariable
from slither.utils.myprettytable import make_pretty_table, MyPrettyTable
from slither.utils.upgradeability import encode_ir_for_halstead


@dataclass
# pylint: disable=too-many-instance-attributes
class HalsteadContractMetrics:
"""Class to hold the Halstead metrics for a single contract."""

contract: Contract
all_operators: List[str] = field(default_factory=list)
all_operands: List[str] = field(default_factory=list)
n1: int = 0
n2: int = 0
N1: int = 0
N2: int = 0
n: int = 0
N: int = 0
S: float = 0
V: float = 0
D: float = 0
E: float = 0
T: float = 0
B: float = 0

def __post_init__(self):
"""Operators and operands can be passed in as constructor args to avoid computing
them based on the contract. Useful for computing metrics for ALL_CONTRACTS"""
if len(self.all_operators) == 0:
self.populate_operators_and_operands()
if len(self.all_operators) > 0:
self.compute_metrics()

def to_dict(self) -> Dict[str, float]:
"""Return the metrics as a dictionary."""
return OrderedDict(
{
"Total Operators": self.N1,
"Unique Operators": self.n1,
"Total Operands": self.N2,
"Unique Operands": self.n2,
"Vocabulary": str(self.n1 + self.n2),
"Program Length": str(self.N1 + self.N2),
"Estimated Length": f"{self.S:.0f}",
"Volume": f"{self.V:.0f}",
"Difficulty": f"{self.D:.0f}",
"Effort": f"{self.E:.0f}",
"Time": f"{self.T:.0f}",
"Estimated Bugs": f"{self.B:.3f}",
}
)

def populate_operators_and_operands(self):
"""Populate the operators and operands lists."""
operators = []
operands = []
if not hasattr(self.contract, "functions"):
return
for func in self.contract.functions:
for node in func.nodes:
for operation in node.irs:
# use operation.expression.type to get the unique operator type
encoded_operator = encode_ir_for_halstead(operation)
operators.append(encoded_operator)

# use operation.used to get the operands of the operation ignoring the temporary variables
operands.extend(
[op for op in operation.used if not isinstance(op, TemporaryVariable)]
)
self.all_operators.extend(operators)
self.all_operands.extend(operands)

def compute_metrics(self, all_operators=None, all_operands=None):
"""Compute the Halstead metrics."""
if all_operators is None:
all_operators = self.all_operators
all_operands = self.all_operands

# core metrics
self.n1 = len(set(all_operators))
self.n2 = len(set(all_operands))
self.N1 = len(all_operators)
self.N2 = len(all_operands)
if any(number <= 0 for number in [self.n1, self.n2, self.N1, self.N2]):
raise ValueError("n1 and n2 must be greater than 0")

# extended metrics 1
self.n = self.n1 + self.n2
self.N = self.N1 + self.N2
self.S = self.n1 * math.log2(self.n1) + self.n2 * math.log2(self.n2)
self.V = self.N * math.log2(self.n)

# extended metrics 2
self.D = (self.n1 / 2) * (self.N2 / self.n2)
self.E = self.D * self.V
self.T = self.E / 18
self.B = (self.E ** (2 / 3)) / 3000


@dataclass
class SectionInfo:
"""Class to hold the information for a section of the report."""

title: str
pretty_table: MyPrettyTable
txt: str


@dataclass
# pylint: disable=too-many-instance-attributes
class HalsteadMetrics:
"""Class to hold the Halstead metrics for all contracts. Contains methods useful for reporting.

There are 3 sections in the report:
1. Core metrics (n1, n2, N1, N2)
2. Extended metrics 1 (n, N, S, V)
3. Extended metrics 2 (D, E, T, B)

"""

contracts: List[Contract] = field(default_factory=list)
contract_metrics: OrderedDict = field(default_factory=OrderedDict)
title: str = "Halstead complexity metrics"
full_txt: str = ""
core: SectionInfo = field(default=SectionInfo)
extended1: SectionInfo = field(default=SectionInfo)
extended2: SectionInfo = field(default=SectionInfo)
CORE_KEYS = (
"Total Operators",
"Unique Operators",
"Total Operands",
"Unique Operands",
)
EXTENDED1_KEYS = (
"Vocabulary",
"Program Length",
"Estimated Length",
"Volume",
)
EXTENDED2_KEYS = (
"Difficulty",
"Effort",
"Time",
"Estimated Bugs",
)
SECTIONS: Tuple[Tuple[str, Tuple[str]]] = (
("Core", CORE_KEYS),
("Extended1", EXTENDED1_KEYS),
("Extended2", EXTENDED2_KEYS),
)

def __post_init__(self):
# Compute the metrics for each contract and for all contracts.
for contract in self.contracts:
self.contract_metrics[contract.name] = HalsteadContractMetrics(contract=contract)

# If there are more than 1 contract, compute the metrics for all contracts.
if len(self.contracts) > 1:
all_operators = [
operator
for contract in self.contracts
for operator in self.contract_metrics[contract.name].all_operators
]
all_operands = [
operand
for contract in self.contracts
for operand in self.contract_metrics[contract.name].all_operands
]
self.contract_metrics["ALL CONTRACTS"] = HalsteadContractMetrics(
None, all_operators=all_operators, all_operands=all_operands
)

# Create the table and text for each section.
data = {
contract.name: self.contract_metrics[contract.name].to_dict()
for contract in self.contracts
}
for (title, keys) in self.SECTIONS:
pretty_table = make_pretty_table(["Contract", *keys], data, False)
section_title = f"{self.title} ({title})"
txt = f"\n\n{section_title}:\n{pretty_table}\n"
self.full_txt += txt
setattr(
self,
title.lower(),
SectionInfo(title=section_title, pretty_table=pretty_table, txt=txt),
)
54 changes: 54 additions & 0 deletions slither/utils/myprettytable.py
devtooligan marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,57 @@ def to_json(self) -> Dict:

def __str__(self) -> str:
return str(self.to_pretty_table())


# **Dict to MyPrettyTable utility functions**


# Converts a dict to a MyPrettyTable. Dict keys are the row headers.
# @param headers str[] of column names
# @param body dict of row headers with a dict of the values
# @param totals bool optional add Totals row
def make_pretty_table(headers: list, body: dict, totals: bool = False) -> MyPrettyTable:
"""
Converts a dict to a MyPrettyTable. Dict keys are the row headers.
Args:
data: dict of row headers with a dict of the values
column_header: str of column name for 1st column
Returns:
MyPrettyTable
"""
table = MyPrettyTable(headers)
for row in body:
table_row = [row] + [body[row][key] for key in headers[1:]]
table.add_row(table_row)
if totals:
table.add_row(["Total"] + [sum([body[row][key] for row in body]) for key in headers[1:]])
return table


def transpose(table):
"""
Converts a dict of dicts to a dict of dicts with the keys transposed
Args:
table: dict of dicts
Returns:
dict of dicts

Example:
in:
{
"dep": {"loc": 0, "sloc": 0, "cloc": 0},
"test": {"loc": 0, "sloc": 0, "cloc": 0},
"src": {"loc": 0, "sloc": 0, "cloc": 0},
}
out:
{
'loc': {'dep': 0, 'test': 0, 'src': 0},
'sloc': {'dep': 0, 'test': 0, 'src': 0},
'cloc': {'dep': 0, 'test': 0, 'src': 0},
}
"""
any_key = list(table.keys())[0]
return {
inner_key: {outer_key: table[outer_key][inner_key] for outer_key in table}
for inner_key in table[any_key]
}
Loading