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
« 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."""
3from __future__ import annotations
5import re
7from ..diff import DiffCode, DiffFile, DiffHunk
8from .helpers import change_suffix, extension
9from .labels import Trust
10from .linescan import collapse_ws
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}
34_CLOSING_BRACKET = {"(": ")", "{": "}"}
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"])
43def _has_trailing_statement(text: str) -> bool:
44 """True if a `;` separates more code, e.g. `import x; doThing()`.
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())
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("(")
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 )
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 )
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
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)
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)
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(("'", '"'))
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.
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
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
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
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
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}")