Coverage for sources / vibelinter / rules / implementations / vbl202.py: 97%

57 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-04 00:00 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21 

22''' VBL202: Import spaghetti detection - prevents excessive relative imports. 

23 

24 

25 

26 Category: Imports / Architecture 

27 Subcategory: Module Coupling 

28 

29 This rule prevents "import spaghetti" by restricting the depth of relative 

30 imports. Specifically: 

31 

32 1. Relative imports with more than 2 parent levels (e.g., `...`, `....`) 

33 are never allowed anywhere. 

34 

35 2. Relative imports with exactly 2 parent levels (e.g., `from .. import`) 

36 are only allowed in private re-export hub modules (configurable, 

37 defaults to `__.py`). 

38 

39 3. Relative imports with exactly 1 level (e.g., `from . import`) are not 

40 allowed in re-export hub modules (`__.py`). This prevents backward 

41 imports since siblings expect to do `from . import __`. 

42 

43 This maintains low coupling between packages and prevents complex 

44 dependency chains that make code hard to understand and refactor. 

45''' 

46 

47 

48from . import __ 

49 

50 

51# Maximum allowed relative import depth 

52_MAX_RELATIVE_IMPORT_DEPTH = 2 

53 

54 

55class VBL202( __.BaseRule ): 

56 ''' Enforces restrictions on relative import depth. ''' 

57 

58 @property 

59 def rule_id( self ) -> str: 

60 return 'VBL202' 

61 

62 def __init__( 

63 self, 

64 filename: str, 

65 wrapper: __.libcst.metadata.MetadataWrapper, 

66 source_lines: tuple[ str, ... ], 

67 reexport_hub_patterns: __.Absential[ tuple[ str, ... ] ] = __.absent, 

68 ) -> None: 

69 super( ).__init__( filename, wrapper, source_lines ) 

70 # Store re-export hub patterns from configuration or use defaults 

71 self._reexport_hub_patterns: tuple[ str, ... ] = ( 

72 reexport_hub_patterns if not __.is_absent( reexport_hub_patterns ) 

73 else ( '__.py', ) ) 

74 # Determine if this file is a re-export hub module 

75 self._is_reexport_hub: bool = self._is_reexport_hub_module( ) 

76 # Collections for violations 

77 self._excessive_depth_imports: list[ __.libcst.ImportFrom ] = [ ] 

78 self._two_level_imports: list[ __.libcst.ImportFrom ] = [ ] 

79 self._one_level_imports_in_hub: list[ __.libcst.ImportFrom ] = [ ] 

80 

81 def visit_ImportFrom( self, node: __.libcst.ImportFrom ) -> bool: 

82 ''' Collects relative import statements with parent references. ''' 

83 # Calculate the relative import depth 

84 depth = self._calculate_relative_depth( node ) 

85 if depth == 0: 

86 # Not a relative import, no violation 

87 return True 

88 if depth > _MAX_RELATIVE_IMPORT_DEPTH: 

89 # More than 2 levels is always a violation 

90 self._excessive_depth_imports.append( node ) 

91 elif depth == _MAX_RELATIVE_IMPORT_DEPTH and not self._is_reexport_hub: 

92 # Exactly 2 levels is only allowed in re-export hubs 

93 self._two_level_imports.append( node ) 

94 elif depth == 1 and self._is_reexport_hub: 

95 # Single-level imports are not allowed in re-export hubs 

96 self._one_level_imports_in_hub.append( node ) 

97 return True 

98 

99 def _analyze_collections( self ) -> None: 

100 ''' Analyzes collected imports and generates violations. ''' 

101 for node in self._excessive_depth_imports: 

102 self._report_excessive_depth_violation( node ) 

103 for node in self._two_level_imports: 

104 self._report_two_level_violation( node ) 

105 for node in self._one_level_imports_in_hub: 

106 self._report_one_level_in_hub_violation( node ) 

107 

108 def _is_reexport_hub_module( self ) -> bool: 

109 ''' Checks if current file matches any re-export hub pattern. 

110 

111 Uses glob patterns from configuration to identify re-export hubs. 

112 Patterns are matched against both the filename and full path. 

113 ''' 

114 file_path = __.pathlib.Path( self.filename ) 

115 for pattern in self._reexport_hub_patterns: 

116 # Try matching against the file path 

117 if file_path.match( pattern ): 

118 return True 

119 # Try matching with wildcard prefix for path-based patterns 

120 if file_path.match( f'*/{pattern}' ): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 return True 

122 return False 

123 

124 def _calculate_relative_depth( self, node: __.libcst.ImportFrom ) -> int: 

125 ''' Calculates the depth of relative import (number of parent levels). 

126 

127 Examples: 

128 - from . import foo -> depth 1 

129 - from .. import foo -> depth 2 

130 - from ... import foo -> depth 3 

131 - from .... import foo -> depth 4 

132 - from foo import bar -> depth 0 (absolute import) 

133 ''' 

134 # Check if this is a relative import 

135 if node.relative: 

136 # Count the dots 

137 # node.relative is a sequence of Dot objects 

138 return len( node.relative ) 

139 return 0 

140 

141 def _report_excessive_depth_violation( 

142 self, node: __.libcst.ImportFrom 

143 ) -> None: 

144 ''' Reports violation for import with more than 2 parent levels. ''' 

145 depth = self._calculate_relative_depth( node ) 

146 dots = '.' * depth 

147 message = ( 

148 f"Excessive relative import depth ({depth} levels): '{dots}'. " 

149 f"Maximum allowed depth is {_MAX_RELATIVE_IMPORT_DEPTH} levels." 

150 ) 

151 self._produce_violation( node, message, severity = 'error' ) 

152 

153 def _report_two_level_violation( 

154 self, node: __.libcst.ImportFrom 

155 ) -> None: 

156 ''' Reports violation for 2-level import outside re-export hub. ''' 

157 patterns_str = ', '.join( self._reexport_hub_patterns ) 

158 message = ( 

159 "Two-level relative import ('from .. import') is only allowed " 

160 f"in re-export hub modules ({patterns_str}). " 

161 "Move this import to a re-export hub or reduce import depth." 

162 ) 

163 self._produce_violation( node, message, severity = 'warning' ) 

164 

165 def _report_one_level_in_hub_violation( 

166 self, node: __.libcst.ImportFrom 

167 ) -> None: 

168 ''' Reports violation for single-level import in re-export hub. ''' 

169 patterns_str = ', '.join( self._reexport_hub_patterns ) 

170 message = ( 

171 "Single-level relative import ('from . import') is not allowed " 

172 f"in re-export hub modules ({patterns_str}). This creates " 

173 "backward imports since siblings expect to import the hub " 

174 "(e.g., 'from . import __'). Import from parent package " 

175 "instead using 'from .. import'." 

176 ) 

177 self._produce_violation( node, message, severity = 'warning' ) 

178 

179 

180# Self-register this rule 

181__.RULE_DESCRIPTORS[ 'VBL202' ] = __.RuleDescriptor( 

182 vbl_code = 'VBL202', 

183 descriptive_name = 'import-spaghetti-detection', 

184 description = 'Prevents excessive relative import depth.', 

185 category = 'imports', 

186 subcategory = 'architecture', 

187 rule_class = VBL202, 

188)