Coverage for common.py: 91%

126 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-08 15:04 -0800

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

2 

3from __future__ import annotations 

4 

5import math 

6from collections import Counter 

7 

8from models import HalsteadMetrics 

9 

10 

11def _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 

25def 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 

62def _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 

67def _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 

75def _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 

112def _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 

124def _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 

135def _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 

152def _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 

174def 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 

190def 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 

202def _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