Coverage for src/pullapprove/trust/imports.py: 96%

70 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-01 17:10 -0500

1"""Trust rule: hunks that only add, remove, or reorder import statements.""" 

2 

3from __future__ import annotations 

4 

5from ..diff import DiffCode, DiffFile, DiffHunk 

6from .helpers import change_suffix, extension 

7from .labels import Trust 

8from .linescan import collapse_ws 

9 

10# File extension -> (import prefixes, multi-line bracket or None for single-line). 

11IMPORT_CONFIG: dict[str, tuple[tuple[str, ...], str | None]] = { 

12 # `export { … }` is intentionally NOT here: adding/removing an export changes 

13 # the module's public API and must stay visible for review, unlike an import. 

14 **dict.fromkeys( 

15 "js jsx ts tsx mjs mts cjs cts".split(), 

16 (("import ", "import{"), "{"), 

17 ), 

18 "py": (("import ", "from "), "("), 

19 "go": (("import ",), "("), 

20 "rs": (("use ",), "{"), 

21 **dict.fromkeys("java kt kts scala groovy gradle".split(), (("import ",), None)), 

22 **dict.fromkeys("c cc cpp cxx h hpp m mm".split(), (("#include",), None)), 

23 "rb": (("require ", "require_relative "), None), 

24 "cs": (("using ",), None), 

25 **dict.fromkeys("swift dart".split(), (("import ",), None)), 

26} 

27 

28_CLOSING_BRACKET = {"(": ")", "{": "}"} 

29 

30 

31def _has_trailing_statement(text: str) -> bool: 

32 """True if a `;` separates more code, e.g. `import x; doThing()`. 

33 

34 A lone import ends at most with a trailing `;`; anything after the first one 

35 is a second statement we must not hide as import churn. 

36 """ 

37 semicolon = text.find(";") 

38 return semicolon != -1 and bool(text[semicolon + 1 :].strip()) 

39 

40 

41def _is_dynamic_import(text: str) -> bool: 

42 """A JS/TS dynamic `import(...)` call — `import` followed (after optional 

43 space) by `(`. It's executable code (lazy/conditional loading), not a static 

44 import declaration, so it must not be hidden as import churn.""" 

45 return text.startswith("import") and text[len("import") :].lstrip().startswith("(") 

46 

47 

48def _is_import_line(content: str, prefixes: tuple[str, ...]) -> bool: 

49 stripped = content.strip() 

50 return ( 

51 bool(stripped) 

52 and stripped.startswith(prefixes) 

53 and not _has_trailing_statement(stripped) 

54 and not _is_dynamic_import(stripped) 

55 ) 

56 

57 

58def _imports_only( 

59 lines: list[DiffCode], prefixes: tuple[str, ...], bracket: str | None 

60) -> bool: 

61 """True if this side is entirely import statements (handling multi-line).""" 

62 if bracket is None: 

63 return all( 

64 not line.content.strip() or _is_import_line(line.content, prefixes) 

65 for line in lines 

66 ) 

67 

68 # Heuristic, not a real parser: we track multi-line continuation by counting 

69 # brackets, which a bracket inside a string or comment would throw off. Import 

70 # statements rarely contain those, and a miscount only fails to label (safe). 

71 close = _CLOSING_BRACKET[bracket] 

72 depth = 0 

73 for line in lines: 

74 text = line.content.strip() 

75 if not text: 

76 continue 

77 # A JS/TS dynamic `import(...)` call is executable code, not a static 

78 # import. Skipped for `(`-bracket languages (Go), where `import (` opens a 

79 # grouped import rather than a call. 

80 if bracket != "(" and _is_dynamic_import(text): 

81 return False 

82 # No import line — including the one that closes a multi-line import 

83 # (which starts at depth > 0) — may carry a trailing statement, or the 

84 # executable code after the `;` would be hidden as import churn. 

85 if _has_trailing_statement(text): 

86 return False 

87 # Outside a bracketed block, every line must start a new import. 

88 if depth == 0 and not text.startswith(prefixes): 

89 return False 

90 depth += text.count(bracket) - text.count(close) 

91 if depth < 0: 

92 return False 

93 return depth == 0 

94 

95 

96def _sorted_imports(lines: list[DiffCode], bracket: str | None) -> list[str]: 

97 """The full import statements on this side, sorted — a bracket-spanned 

98 multi-line import is joined into one string so it compares by its complete 

99 text (members included), not just its opening line. (Callers pass only 

100 import-and-blank lines, guaranteed by `_imports_only`.)""" 

101 statements: list[str] = [] 

102 current: list[str] = [] 

103 depth = 0 

104 for line in lines: 

105 text = line.content.strip() 

106 if not text: 

107 continue 

108 current.append(text) 

109 if bracket is not None: 

110 depth += text.count(bracket) - text.count(_CLOSING_BRACKET[bracket]) 

111 if depth <= 0: # statement complete (single-line, or the closing bracket) 

112 statements.append(collapse_ws(" ".join(current))) 

113 current = [] 

114 depth = 0 

115 if current: # an unclosed trailing group (defensive; shouldn't occur) 

116 statements.append(collapse_ws(" ".join(current))) 

117 return sorted(statements) 

118 

119 

120def _is_import_reorder( 

121 added: list[DiffCode], removed: list[DiffCode], bracket: str | None 

122) -> bool: 

123 """Same set of whole import statements, just in a different order.""" 

124 added_imports = _sorted_imports(added, bracket) 

125 return bool(added_imports) and added_imports == _sorted_imports(removed, bracket) 

126 

127 

128def _imports(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

129 config = IMPORT_CONFIG.get(extension(file)) 

130 if not config: 

131 return None 

132 prefixes, bracket = config 

133 added, removed = hunk.added_lines, hunk.removed_lines 

134 if not added and not removed: 

135 return None 

136 # `from __future__ import …` is a compiler directive, not a plain import: 

137 # adding one changes the whole module's semantics, and moving one below 

138 # another import is a SyntaxError — never trivial, so decline the hunk. 

139 if any("__future__" in line.content for line in (*added, *removed)): 

140 return None 

141 if not _imports_only(added, prefixes, bracket) or not _imports_only( 

142 removed, prefixes, bracket 

143 ): 

144 return None 

145 if added and removed and _is_import_reorder(added, removed, bracket): 

146 return Trust.IMPORTS_REORDERED 

147 # change_suffix yields added/removed/modified — each a real Trust member. 

148 return Trust(f"imports:{change_suffix(hunk)}")