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