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

118 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-19 16:21 -0500

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

2 

3from __future__ import annotations 

4 

5import re 

6 

7from ..diff import DiffCode, DiffFile, DiffHunk 

8from .helpers import change_suffix, extension 

9from .labels import Trust 

10from .linescan import collapse_ws 

11 

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

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

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

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

16 **dict.fromkeys( 

17 ["js", "jsx", "ts", "tsx", "mjs", "mts", "cjs", "cts"], 

18 (("import ", "import{"), "{"), 

19 ), 

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

21 "go": (("import ",), "("), 

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

23 **dict.fromkeys( 

24 ["java", "kt", "kts", "scala", "groovy", "gradle"], (("import ",), None) 

25 ), 

26 **dict.fromkeys( 

27 ["c", "cc", "cpp", "cxx", "h", "hpp", "m", "mm"], (("#include",), None) 

28 ), 

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

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

31 **dict.fromkeys(["swift", "dart"], (("import ",), None)), 

32} 

33 

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

35 

36# The extensions IMPORT_CONFIG groups under the JS/TS and C-family entries 

37# above, repeated here so the reorder and source-swap checks below can key off 

38# the language without re-deriving it from IMPORT_CONFIG's shared tuples. 

39_JS_TS_EXTS = frozenset(["js", "jsx", "ts", "tsx", "mjs", "mts", "cjs", "cts"]) 

40_C_EXTS = frozenset(["c", "cc", "cpp", "cxx", "h", "hpp", "m", "mm"]) 

41 

42 

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

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

45 

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

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

48 """ 

49 semicolon = text.find(";") 

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

51 

52 

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

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

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

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

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

58 

59 

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

61 stripped = content.strip() 

62 return ( 

63 bool(stripped) 

64 and stripped.startswith(prefixes) 

65 and not _has_trailing_statement(stripped) 

66 and not _is_dynamic_import(stripped) 

67 ) 

68 

69 

70def _imports_only( 

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

72) -> bool: 

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

74 if bracket is None: 

75 return all( 

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

77 for line in lines 

78 ) 

79 

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

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

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

83 close = _CLOSING_BRACKET[bracket] 

84 depth = 0 

85 for line in lines: 

86 text = line.content.strip() 

87 if not text: 

88 continue 

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

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

91 # grouped import rather than a call. 

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

93 return False 

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

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

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

97 if _has_trailing_statement(text): 

98 return False 

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

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

101 return False 

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

103 if depth < 0: 

104 return False 

105 return depth == 0 

106 

107 

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

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

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

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

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

113 statements: list[str] = [] 

114 current: list[str] = [] 

115 depth = 0 

116 for line in lines: 

117 text = line.content.strip() 

118 if not text: 

119 continue 

120 current.append(text) 

121 if bracket is not None: 

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

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

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

125 current = [] 

126 depth = 0 

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

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

129 return sorted(statements) 

130 

131 

132def _is_import_reorder( 

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

134) -> bool: 

135 """Same set of whole import statements, just in a different order (an empty 

136 result on both sides is not a reorder — there's nothing to reorder).""" 

137 added_imports = _sorted_imports(added, bracket) 

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

139 

140 

141def _is_side_effect_import(statement: str) -> bool: 

142 """A JS/TS side-effect import — `import 'spec';` — binds no names. Unlike 

143 `import x from 'spec'`, its only purpose is running the module's top-level 

144 code, so two of them (`import './polyfills'; import './init-sentry';`) can 

145 depend on running in a particular order even though the *set* of specifiers 

146 is unchanged.""" 

147 after_import = statement[len("import") :].lstrip() 

148 return after_import.startswith(("'", '"')) 

149 

150 

151def _reorder_is_trivial(statements: list[str], ext: str) -> bool: 

152 """False when reordering these (already-confirmed-identical-as-a-set) 

153 import statements can change behavior, so the reorder must still be 

154 declined rather than trusted. 

155 

156 - C/C++/Obj-C `#include`: can define macros/typedefs or trigger conditional 

157 compilation that a later include depends on — order is always 

158 potentially meaningful, so reorders are never trusted for this family. 

159 - JS/TS: trivial unless the reordered lines include a bare side-effect 

160 import (see `_is_side_effect_import`). 

161 """ 

162 if ext in _C_EXTS: 

163 return False 

164 if ext in _JS_TS_EXTS: 

165 return not any(_is_side_effect_import(stmt) for stmt in statements) 

166 return True 

167 

168 

169# Languages where `_import_source` can reliably split "module" from "names" — 

170# i.e. a paired add/remove commonly keeps the module and only varies the 

171# imported names. Extending this to every configured language is out of scope 

172# for now; for the rest, `_same_import_sources` can't tell a name-only edit 

173# from a module swap, so it stays permissive (matching the prior behavior). 

174_SOURCE_AWARE_EXTS = frozenset({"py"}) | _JS_TS_EXTS | _C_EXTS 

175 

176 

177def _import_source(statement: str, ext: str) -> str | None: 

178 """The module/path this single import statement pulls from, or None if we 

179 can't confidently identify it (fail safe: an unparsed statement must not 

180 be treated as matching another one).""" 

181 if ext == "py": 

182 if statement.startswith("from "): 

183 module, _, _ = statement[len("from ") :].partition(" import") 

184 return module.strip() or None 

185 if statement.startswith("import "): 

186 token = re.split(r"[,\s]", statement[len("import ") :], maxsplit=1)[0] 

187 return token or None 

188 return None 

189 if ext in _JS_TS_EXTS: 

190 match = re.search(r"""from\s*(['"])(.*?)\1""", statement) 

191 if match: 

192 return match.group(2) 

193 # No `from` clause: a bare side-effect import, where the quoted spec 

194 # itself IS the source (`import 'spec';`). 

195 after_import = statement[len("import") :].lstrip() 

196 match = re.match(r"""(['"])(.*?)\1""", after_import) 

197 return match.group(2) if match else None 

198 if ext in _C_EXTS: 

199 match = re.search(r'["<]([^">]+)[">]', statement) 

200 return match.group(1) if match else None 

201 return None 

202 

203 

204def _same_import_sources( 

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

206) -> bool: 

207 """True only if every added and removed import statement resolves to the 

208 same set of source modules — i.e. the hunk adds/removes names within an 

209 unchanged set of modules, not a swap to a different module 

210 (`-from foo import bar` / `+from evil import bar`).""" 

211 if ext not in _SOURCE_AWARE_EXTS: 

212 # We can't split "module" from "names" for this language, so we can't 

213 # confirm a paired modification keeps the same source rather than 

214 # swapping to a different module — e.g. Ruby `require 'safe'` -> 

215 # `require 'evil'`, which executes a different file. Decline (show it) 

216 # rather than trust the swap blindly; pure add/remove/reorder still 

217 # label via their own paths. 

218 return False 

219 added_sources = {_import_source(s, ext) for s in _sorted_imports(added, bracket)} 

220 removed_sources = { 

221 _import_source(s, ext) for s in _sorted_imports(removed, bracket) 

222 } 

223 # A statement we couldn't parse (None) is a hard stop — fail safe, don't trust. 

224 if None in added_sources or None in removed_sources: 

225 return False 

226 return added_sources == removed_sources 

227 

228 

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

230 ext = extension(file) 

231 config = IMPORT_CONFIG.get(ext) 

232 if not config: 

233 return None 

234 prefixes, bracket = config 

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

236 if not added and not removed: 

237 return None 

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

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

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

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

242 return None 

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

244 removed, prefixes, bracket 

245 ): 

246 return None 

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

248 # Same statements, different order. Whether that's trustworthy depends 

249 # on the language — if not, decline outright rather than falling 

250 # through to the "modified" check below, which only compares source 

251 # modules and would trivially pass a same-statement reorder. 

252 statements = _sorted_imports(added, bracket) 

253 if not _reorder_is_trivial(statements, ext): 

254 return None 

255 return Trust.IMPORTS_REORDERED 

256 suffix = change_suffix(hunk) 

257 # A "modified" hunk (both sides non-empty, not a pure reorder) is only 

258 # trustworthy when it's adding/removing names within the same set of 

259 # source modules — not swapping to a different module. 

260 if suffix == "modified" and not _same_import_sources(added, removed, bracket, ext): 

261 return None 

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

263 return Trust(f"imports:{suffix}")