Coverage for common.py: 91%

1"""Shared helpers for cognitive complexity analyzers (TypeScript & Go)."""

2

3 from __future__ import annotations

4

5 import math

6 from collections import Counter

7

8 from models import HalsteadMetrics

9

10

11 def _halstead_derived(n1, n2, N1, N2, n, N):

12 """Compute derived Halstead measures from base counts."""

13 volume = N * math.log2(n)

14 difficulty = (n1 / 2) * (N2 / n2)

15 effort = difficulty * volume

16 return (

17 round(volume, 2),

18 round(difficulty, 2),

19 round(effort, 2),

20 round(effort / 18, 2),

21 round(effort ** (2 / 3) / 3000, 4),

22 )

23

24

25 def compute_halstead(operators: list[str], operands: list[str]) -> HalsteadMetrics:

26 """Compute Halstead metrics from raw operator and operand lists."""

27 op_counts = Counter(operators)

28 od_counts = Counter(operands)

29

30 n1 = len(op_counts)

31 n2 = len(od_counts)

32 N1 = sum(op_counts.values())

33 N2 = sum(od_counts.values())

34

35 n = n1 + n2 # vocabulary

36 N = N1 + N2 # length

37

38 if n == 0 or n2 == 0:

39 return HalsteadMetrics(n1=n1, n2=n2, N1=N1, N2=N2, vocabulary=n, length=N)

40

41 volume, difficulty, effort, time, bugs = _halstead_derived(n1, n2, N1, N2, n, N)

42 return HalsteadMetrics(

43 n1=n1,

44 n2=n2,

45 N1=N1,

46 N2=N2,

47 vocabulary=n,

48 length=N,

49 volume=volume,

50 difficulty=difficulty,

51 effort=effort,

52 time=time,

53 bugs=bugs,

54 )

55

56

57# ---------------------------------------------------------------------------

58# Line-metric helpers (LOC / SLOC) -- shared across all languages

59# ---------------------------------------------------------------------------

60

61

62 def _compute_loc(source: str) -> int:

63 """Count lines of code (non-blank lines)."""

64 return sum(1 for line in source.splitlines() if line.strip())

65

66

67 def _collect_comment_nodes(node, acc: list) -> None:

68 """Recursively collect all comment nodes from the AST."""

69 if node.type == "comment":

70 acc.append(node)

71 for child in node.children:

72 _collect_comment_nodes(child, acc)

73

74

75 def _compute_sloc(source: str, root_node) -> int:

76 """Count source lines of code (non-blank, non-comment-only lines)."""

77 lines = source.splitlines()

78 comment_only: set[int] = set()

79

80 comments: list = []

81 _collect_comment_nodes(root_node, comments)

82

83 for comment in comments:

84 start_line = comment.start_point[0]

85 end_line = comment.end_point[0]

86 start_col = comment.start_point[1]

87 end_col = comment.end_point[1]

88

89 for line_idx in range(start_line, end_line + 1):

90 if line_idx >= len(lines):

91 continue

92 line = lines[line_idx]

93 if start_line == end_line:

94 # Single-line comment -- comment-only if nothing else on the line

95 if not line[:start_col].strip() and not line[end_col:].strip():

96 comment_only.add(line_idx)

97 elif line_idx == start_line:

98 if not line[:start_col].strip():

99 comment_only.add(line_idx)

100 elif line_idx == end_line:

101 if not line[end_col:].strip():

102 comment_only.add(line_idx)

103 else:

104 # Interior line of a multi-line comment

105 comment_only.add(line_idx)

106

107 return sum(

108 1 for i, line in enumerate(lines) if line.strip() and i not in comment_only

109 )

110

111

112 def _unwrap_parens(node):

113 """Unwrap parenthesized_expression nodes to get the inner expression."""

114 while node.type == "parenthesized_expression":

115 for child in node.children:

116 if child.type not in ("(", ")"):

117 node = child

118 break

119 else:

120 break

121 return node

122

123

124 def _is_logical_binary(node) -> bool:

125 """Check if a node (possibly parenthesized) is a binary_expression with && or ||."""

126 node = _unwrap_parens(node)

127 if node.type != "binary_expression":

128 return False

129 for child in node.children:

130 if child.type in ("&&", "||"):

131 return True

132 return False

133

134

135 def _flatten_boolean_ops(node, ops: list, right_subtrees: list) -> None:

136 """Flatten a left-associative binary_expression tree of logical operators."""

137 node = _unwrap_parens(node)

138 if not _is_logical_binary(node):

139 return

140 children = node.children

141 if len(children) >= 3:

142 left = children[0]

143 operator = children[1]

144 right = children[2]

145 if _is_logical_binary(left):

146 _flatten_boolean_ops(left, ops, right_subtrees)

147 ops.append(operator.type)

148 if _is_logical_binary(right):

149 right_subtrees.append(_unwrap_parens(right))

150

151

152 def _count_bool_ops(node) -> int:

153 """Count logical binary operators (&& / ||) in an expression subtree."""

154 unwrapped = _unwrap_parens(node)

155 if unwrapped.type == "binary_expression":

156 is_logical = False

157 for child in unwrapped.children:

158 if child.type in ("&&", "||"):

159 is_logical = True

160 break

161 count = 1 if is_logical else 0

162 # Recurse into the unwrapped node's children to avoid double-counting

163 for child in unwrapped.children:

164 if child.type not in ("&&", "||"):

165 count += _count_bool_ops(child)

166 return count

167 # For non-binary nodes, recurse normally

168 count = 0

169 for child in unwrapped.children:

170 count += _count_bool_ops(child)

171 return count

172

173

174 def compute_maintainability_index(

175 halstead_volume: float, cyclomatic: int, loc: int

176) -> float:

177 """Compute the VSCode Maintainability Index for a single function.

178

179 MI = max(0, (171 - 5.2*ln(HV) - 0.23*CC - 16.2*ln(LOC)) * 100 / 171)

180 Scale: 0-100, higher = more maintainable.

181 """

182 if halstead_volume <= 0 or loc <= 0:

183 return 100.0

184 mi = (

185 171 - 5.2 * math.log(halstead_volume) - 0.23 * cyclomatic - 16.2 * math.log(loc)

186 )

187 return round(max(0.0, mi * 100 / 171), 2)

188

189

190 def compute_ldi(volume: float, vocabulary: int, n2: int, difficulty: float) -> float:

191 """Compute the Logic Density Index for a single function.

192

193 LDI = min(100, (vocabulary/100 * 40) + (difficulty/60 * 40) + (n2/volume * 200))

194 Scale: 0-100, higher = more complex logic.

195 """

196 if volume <= 0:

197 return 0.0

198 ldi = (vocabulary / 100 * 40) + (difficulty / 60 * 40) + (n2 / volume * 200)

199 return round(min(100.0, ldi), 2)

200

201

202 def _score_boolean_chain(node, flatten_fn=None) -> int:

203 """Score a boolean chain in binary_expression nodes using && and ||.

204

205 Rules:

206 - +1 for the first boolean operator in a sequence

207 - +1 for each switch between operator types (&& -> ||, || -> &&)

208 - Same consecutive operators don't add extra cost

209

210 If *flatten_fn* is provided it replaces the default `_flatten_boolean_ops`.

211 """

212 if flatten_fn is None:

213 flatten_fn = _flatten_boolean_ops

214 ops = []

215 right_subtrees = []

216 flatten_fn(node, ops, right_subtrees)

217

218 score = 0

219 if ops:

220 score = 1

221 for i in range(1, len(ops)):

222 if ops[i] != ops[i - 1]:

223 score += 1

224

225 for subtree in right_subtrees:

226 score += _score_boolean_chain(subtree, flatten_fn=flatten_fn)

227

228 return score