reducio 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reducio/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """reducio — semantic code compression engine."""
2
+
3
+ __version__ = "1.0.0"
reducio/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from reducio.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,17 @@
1
+ """
2
+ Agent implementations for code analysis and refactoring.
3
+ """
4
+
5
+ from reducio.agents.analyzer import AnalyzerAgent
6
+ from reducio.agents.deduplicator import DeduplicatorAgent
7
+ from reducio.agents.idiomatizer import IdiomatizerAgent
8
+ from reducio.agents.pattern import PatternAgent
9
+ from reducio.agents.quality_checker import QualityCheckerAgent
10
+
11
+ __all__ = [
12
+ "AnalyzerAgent",
13
+ "DeduplicatorAgent",
14
+ "IdiomatizerAgent",
15
+ "PatternAgent",
16
+ "QualityCheckerAgent",
17
+ ]
@@ -0,0 +1,17 @@
1
+ """Analyzer agent using the shared, versioned source metrics."""
2
+
3
+ from reducio.analysis import analyze_files
4
+ from reducio.models import AnalyzeRequest, AnalyzeResult
5
+ from reducio.workspace import Workspace
6
+
7
+
8
+ class AnalyzerAgent:
9
+ def __init__(self, workspace: Workspace):
10
+ self.workspace = workspace
11
+
12
+ async def analyze(self, request: AnalyzeRequest) -> AnalyzeResult:
13
+ return analyze_files(
14
+ request.files or self.workspace.list_files(),
15
+ self.workspace.cfg,
16
+ request.path,
17
+ )
reducio/agents/base.py ADDED
@@ -0,0 +1,138 @@
1
+ """Base agent class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import TYPE_CHECKING
7
+
8
+ from reducio.models import FileChange, PlanDiagnostic, PlanningProvenance, RefactorPlan
9
+ from reducio.plan_review import validate_plan
10
+ from reducio.session import SessionStore
11
+ from reducio.utils.code_utils import strip_code_fence
12
+ from reducio.workspace import Workspace
13
+
14
+ if TYPE_CHECKING:
15
+ from reducio.llm.router import LLMRouter
16
+
17
+
18
+ class ModelRewriteError(RuntimeError):
19
+ pass
20
+
21
+
22
+ class BaseAgent:
23
+ def __init__(
24
+ self,
25
+ workspace: Workspace | None = None,
26
+ llm_router: LLMRouter | None = None,
27
+ session_store: SessionStore | None = None,
28
+ ):
29
+ self.workspace = workspace
30
+ self.llm = llm_router
31
+ self.session_store = session_store or SessionStore(
32
+ str(workspace.root / ".reducio" / "sessions") if workspace else ".reducio/sessions"
33
+ )
34
+ self._session_plans: dict[str, RefactorPlan] = {}
35
+ self._begin_plan()
36
+
37
+ def _begin_plan(self, allow_fallback: bool = False) -> None:
38
+ self.allow_fallback = allow_fallback
39
+ self.diagnostics: list[PlanDiagnostic] = []
40
+ self.provenance: list[PlanningProvenance] = []
41
+
42
+ def _generate_session_id(self) -> str:
43
+ return str(uuid.uuid4())
44
+
45
+ def _save_plan(self, plan: RefactorPlan, command_type: str) -> None:
46
+ self.session_store.save_plan(plan, command_type=command_type)
47
+ self._session_plans[plan.session_id] = plan
48
+
49
+ def get_plan(self, session_id: str) -> RefactorPlan | None:
50
+ return self.session_store.load_plan(session_id)
51
+
52
+ def _file_content_path(self, file) -> tuple[str, str]:
53
+ if hasattr(file, "content"):
54
+ return file.content, file.path
55
+ return file["content"], file["path"]
56
+
57
+ def _llm_enabled(self) -> bool:
58
+ # An explicit model request must not silently degrade when no router is wired.
59
+ return bool(self.workspace and self.workspace.cfg.model)
60
+
61
+ async def _llm_rewrite(
62
+ self, content: str, path: str, instruction: str, description: str
63
+ ) -> FileChange | None:
64
+ """Ask the LLM to rewrite a whole module; returns a reviewable change or None."""
65
+ prompt = (
66
+ f"{instruction}\nReturn ONLY the complete rewritten module, no prose.\n\n"
67
+ f"```python\n{content}\n```"
68
+ )
69
+ model = self.workspace.cfg.model if self.workspace else ""
70
+ model = "custom endpoint" if "://" in model else model
71
+ try:
72
+ if self.llm is None:
73
+ raise ModelRewriteError("No model router available")
74
+ raw = await self.llm.complete(
75
+ prompt, system_prompt="You are an expert Python engineer."
76
+ )
77
+ code = strip_code_fence(raw)
78
+ if not code.strip():
79
+ raise ValueError("Empty model response")
80
+ compile(code, path, "exec")
81
+ except Exception:
82
+ self.diagnostics.append(
83
+ PlanDiagnostic(
84
+ code="model_failed",
85
+ file=path,
86
+ message="Model rewrite failed or returned empty/invalid Python"
87
+ + (
88
+ "; explicit fallback enabled"
89
+ if self.allow_fallback
90
+ else "; no application permitted"
91
+ ),
92
+ severity="warning" if self.allow_fallback else "error",
93
+ )
94
+ )
95
+ self.provenance.append(
96
+ PlanningProvenance(file=path, engine="model", outcome="failed", model=model)
97
+ )
98
+ raise ModelRewriteError("Model rewrite unavailable") from None
99
+ self.provenance.append(
100
+ PlanningProvenance(
101
+ file=path,
102
+ engine="model",
103
+ outcome="unchanged" if code.strip() == content.strip() else "proposed",
104
+ model=model,
105
+ )
106
+ )
107
+ if code.strip() == content.strip():
108
+ return None
109
+ return FileChange(
110
+ path=path,
111
+ original=content,
112
+ modified=code if code.endswith("\n") else code + "\n",
113
+ description=description,
114
+ )
115
+
116
+ def _finalize_plan(
117
+ self, changes: list, description: str, command_type: str, **plan_kw
118
+ ) -> RefactorPlan:
119
+ plan = RefactorPlan(
120
+ session_id=self._generate_session_id(),
121
+ changes=changes,
122
+ description=description,
123
+ diagnostics=self.diagnostics,
124
+ provenance=self.provenance,
125
+ **plan_kw,
126
+ )
127
+ # Coalesce truly identical changes, but never silently pick one conflicting version.
128
+ unique = []
129
+ for change in plan.changes:
130
+ if change not in unique:
131
+ unique.append(change)
132
+ plan.changes = unique
133
+ plan.diagnostics.extend(
134
+ validate_plan(plan, self.workspace.root if self.workspace else None)
135
+ )
136
+ plan.complete = not any(item.severity == "error" for item in plan.diagnostics)
137
+ self._save_plan(plan, command_type)
138
+ return plan
@@ -0,0 +1,194 @@
1
+ """Deduplicator agent — semantic duplicate detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import builtins
7
+ import symtable
8
+ from typing import TYPE_CHECKING
9
+
10
+ from reducio.agents.base import BaseAgent
11
+
12
+ if TYPE_CHECKING:
13
+ from reducio.embeddings.service import EmbeddingService
14
+ from reducio.models import (
15
+ CodeBlock,
16
+ DeduplicateRequest,
17
+ FileChange,
18
+ FileInfo,
19
+ Language,
20
+ PlanDiagnostic,
21
+ PlanningProvenance,
22
+ RefactorPlan,
23
+ )
24
+ from reducio.parse import ParserError, get_complexity
25
+ from reducio.plan_review import advisory_path
26
+ from reducio.repo import detect_language
27
+ from reducio.session import SessionStore
28
+ from reducio.workspace import Workspace
29
+
30
+
31
+ class DeduplicatorAgent(BaseAgent):
32
+ workspace: Workspace
33
+
34
+ def __init__(
35
+ self,
36
+ workspace: Workspace,
37
+ embedding_service: EmbeddingService,
38
+ llm_router=None,
39
+ session_store: SessionStore | None = None,
40
+ ):
41
+ super().__init__(workspace, llm_router, session_store)
42
+ self.embedding_service = embedding_service
43
+
44
+ async def find_duplicates(self, request: DeduplicateRequest) -> RefactorPlan:
45
+ self._begin_plan()
46
+ files = request.files or self.workspace.list_files()
47
+ if not self.embedding_service.is_using_real_embeddings:
48
+ self.diagnostics.append(
49
+ PlanDiagnostic(
50
+ code="embeddings_unavailable",
51
+ severity="error",
52
+ message="Semantic embeddings unavailable; install reducio[embeddings] and retry.",
53
+ )
54
+ )
55
+ return self._finalize_plan(
56
+ [],
57
+ "Semantic embeddings unavailable; no duplicates analyzed. See installation docs for the embeddings extra.",
58
+ "deduplicate",
59
+ )
60
+ blocks = self._extract_blocks(files)
61
+ try:
62
+ groups = await self.embedding_service.find_duplicates(
63
+ blocks, request.similarity_threshold
64
+ )
65
+ except Exception:
66
+ self.diagnostics.append(
67
+ PlanDiagnostic(
68
+ code="embeddings_failed",
69
+ severity="error",
70
+ message="Semantic duplicate search failed; no application permitted.",
71
+ )
72
+ )
73
+ groups = []
74
+ self.provenance.append(
75
+ PlanningProvenance(
76
+ file="",
77
+ engine="embeddings",
78
+ outcome=(
79
+ "failed" if any(d.severity == "error" for d in self.diagnostics) else "scanned"
80
+ ),
81
+ )
82
+ )
83
+ changes = []
84
+ for group in groups:
85
+ if len(group) >= 2:
86
+ ch = self._create_dedup_change(group)
87
+ if ch:
88
+ changes.append(ch)
89
+ return self._finalize_plan(
90
+ changes,
91
+ f"Found {len(groups)} duplicate group(s); proposing {len(changes)} shared-util "
92
+ "suggestion(s) (review before adopting — call sites are not rewritten).",
93
+ "deduplicate",
94
+ )
95
+
96
+ def _extract_blocks(self, files: list[FileInfo]) -> list[CodeBlock]:
97
+ blocks: list[CodeBlock] = []
98
+ for f in files:
99
+ lang = detect_language(f.path)
100
+ if lang == Language.UNKNOWN:
101
+ continue
102
+ try:
103
+ self.workspace.get_symbols(f.path, f.content)
104
+ tree = ast.parse(f.content)
105
+ module_scope = symtable.symtable(f.content, f.path, "exec")
106
+ except ParserError, SyntaxError, ValueError:
107
+ self.diagnostics.append(
108
+ PlanDiagnostic(
109
+ code="parser_failed",
110
+ file=f.path,
111
+ severity="error",
112
+ message="Python parsing failed; duplicate analysis is incomplete.",
113
+ )
114
+ )
115
+ continue
116
+ top = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
117
+ for node in ast.walk(tree):
118
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node not in top:
119
+ self.diagnostics.append(
120
+ PlanDiagnostic(
121
+ code="unsupported_scope",
122
+ file=f.path,
123
+ message=f"Skipped {node.name}: methods and nested functions are not standalone utilities.",
124
+ )
125
+ )
126
+ for node in top:
127
+ content = ast.get_source_segment(f.content, node) or ""
128
+ bindings = {
129
+ s.get_name()
130
+ for s in module_scope.get_symbols()
131
+ if s.is_assigned() or s.is_imported()
132
+ }
133
+ dependencies = _dependencies(content, node.name, bindings)
134
+ if node.decorator_list or dependencies:
135
+ self.diagnostics.append(
136
+ PlanDiagnostic(
137
+ code="dependencies",
138
+ file=f.path,
139
+ message=f"Skipped {node.name}: decorators or external dependencies require review.",
140
+ )
141
+ )
142
+ continue
143
+ try:
144
+ metrics = get_complexity(content)
145
+ except SyntaxError, ValueError:
146
+ continue # invalid snippets have no trustworthy numeric score
147
+ blocks.append(
148
+ CodeBlock(
149
+ id=f"{f.path}:{node.lineno}:{node.name}",
150
+ file=f.path,
151
+ start_line=node.lineno,
152
+ end_line=node.end_lineno or node.lineno,
153
+ content=content,
154
+ language=lang,
155
+ symbol_type="function",
156
+ symbol_name=node.name,
157
+ metrics=metrics,
158
+ )
159
+ )
160
+ return blocks
161
+
162
+ def _create_dedup_change(self, group: list[CodeBlock]) -> FileChange | None:
163
+ primary = group[0]
164
+ return FileChange(
165
+ path=advisory_path(
166
+ "utils", primary.file, f"{primary.symbol_name}_{primary.start_line}_dedup"
167
+ ),
168
+ original="",
169
+ modified=primary.content,
170
+ description=(
171
+ f"Proposed shared util for '{primary.symbol_name}' from {len(group)} sites "
172
+ "(suggestion only; applying writes the utility module; "
173
+ "originals and call sites are not rewritten)"
174
+ ),
175
+ )
176
+
177
+
178
+ def _dependencies(content: str, name: str, module_bindings: set[str]) -> set[str]:
179
+ """Conservatively reject names that need the original module's namespace."""
180
+ table = symtable.symtable(content, "<advisory>", "exec")
181
+ required: set[str] = set()
182
+ pending = [table]
183
+ while pending:
184
+ scope = pending.pop()
185
+ required.update(
186
+ s.get_name()
187
+ for s in scope.get_symbols()
188
+ if (s.is_referenced() or s.is_declared_global()) and s.is_global()
189
+ )
190
+ pending.extend(scope.get_children())
191
+ # Recursion is self-contained; builtins need no accompanying import.
192
+ dynamic = {"globals", "locals", "eval", "exec", "__import__"}
193
+ allowed_builtins = set(dir(builtins)) - module_bindings - dynamic
194
+ return required - allowed_builtins - {name}
@@ -0,0 +1,243 @@
1
+ """
2
+ Idiomatizer agent for transforming code to idiomatic patterns (Python heuristics).
3
+ """
4
+
5
+ import ast
6
+ import re
7
+
8
+ from reducio.agents.base import BaseAgent, ModelRewriteError
9
+ from reducio.models import (
10
+ FileChange,
11
+ IdiomatizeRequest,
12
+ Language,
13
+ PlanDiagnostic,
14
+ PlanningProvenance,
15
+ RefactorPlan,
16
+ )
17
+ from reducio.repo import detect_language
18
+ from reducio.session import SessionStore
19
+
20
+
21
+ class IdiomatizerAgent(BaseAgent):
22
+ def __init__(self, workspace=None, llm_router=None, session_store: SessionStore | None = None):
23
+ super().__init__(workspace, llm_router, session_store)
24
+
25
+ async def idiomatize(self, request: IdiomatizeRequest) -> RefactorPlan:
26
+ self._begin_plan(request.allow_fallback)
27
+ changes = []
28
+ idioms = 0
29
+ for file in request.files:
30
+ try:
31
+ change, count = await self._idiomatize_file(file)
32
+ except ModelRewriteError:
33
+ break
34
+ if change:
35
+ changes.append(change)
36
+ idioms += count
37
+ return self._finalize_plan(
38
+ changes,
39
+ f"Found {idioms} opportunities for idiomatic improvements in {len(changes)} file(s).",
40
+ "idiomatize",
41
+ )
42
+
43
+ async def _idiomatize_file(self, file) -> tuple[FileChange | None, int]:
44
+ content, path = self._file_content_path(file)
45
+ if detect_language(path) != Language.PYTHON:
46
+ return None, 0
47
+ try:
48
+ ast.parse(content) # can't safely rewrite (or validate) a file that doesn't parse
49
+ except SyntaxError:
50
+ self.diagnostics.append(
51
+ PlanDiagnostic(
52
+ code="invalid_source", file=path, message="Skipped invalid Python source"
53
+ )
54
+ )
55
+ return None, 0
56
+ if self._llm_enabled():
57
+ try:
58
+ change = await self._llm_rewrite(
59
+ content,
60
+ path,
61
+ "Rewrite the following Python module to be more idiomatic and concise without changing behaviour.",
62
+ "LLM idiomatic rewrite",
63
+ )
64
+ return change, int(change is not None)
65
+ except ModelRewriteError:
66
+ if not self.allow_fallback:
67
+ raise
68
+ change, count = self._idiomatize_python(content, path)
69
+ self.provenance.append(
70
+ PlanningProvenance(
71
+ file=path, engine="heuristic", outcome="proposed" if change else "unchanged"
72
+ )
73
+ )
74
+ return change, count
75
+
76
+ def _idiomatize_python(self, content: str, path: str) -> tuple[FileChange | None, int]:
77
+ """Collect every idiom as a line span and emit one whole-file change.
78
+
79
+ Spans are applied to a copy of the file in reverse so earlier line
80
+ numbers stay valid, and the resulting diff is file-relative (the old
81
+ per-snippet changes produced diffs that applied at line 1 — see ROADMAP P0).
82
+ """
83
+ lines = content.split("\n")
84
+ spans = [] # (start, end, replacement, description)
85
+ i = 0
86
+ while i < len(lines):
87
+ result = self._try_python_idiom(lines[i], lines, i)
88
+ if result:
89
+ original, modified, description = result
90
+ consumed = original.count("\n") + 1
91
+ spans.append((i, i + consumed, modified, f"L{i + 1}: {description}"))
92
+ i += consumed
93
+ else:
94
+ i += 1
95
+ if not spans:
96
+ return None, 0
97
+ new_lines = list(lines)
98
+ for start, end, replacement, _ in reversed(spans):
99
+ new_lines[start:end] = replacement.split("\n")
100
+ return (
101
+ FileChange(
102
+ path=path,
103
+ original=content,
104
+ modified="\n".join(new_lines),
105
+ description="; ".join(desc for *_, desc in spans),
106
+ ),
107
+ len(spans),
108
+ )
109
+
110
+ def _try_python_idiom(self, line: str, lines: list[str], idx: int) -> tuple | None:
111
+ return (
112
+ self._filtered_list_comp(lines, idx)
113
+ or self._dict_comp(lines, idx)
114
+ or self._simple_list_comp(lines, idx)
115
+ or self._compare_to_none(lines, idx)
116
+ or self._truthiness(lines, idx)
117
+ or self._or_chain_to_in(lines, idx)
118
+ )
119
+
120
+ @staticmethod
121
+ def _is_boolean_line(line: str) -> bool:
122
+ return line.strip().startswith(("if ", "elif ", "while "))
123
+
124
+ @staticmethod
125
+ def _references(var: str, *exprs: str) -> bool:
126
+ """True if `var` appears as a name in any expr — unsafe to fold into a comprehension.
127
+
128
+ A `for/append` loop whose value, iterable, or filter reads the accumulator
129
+ (e.g. order-preserving dedup: `if v not in u: u.append(v)`) changes behaviour
130
+ when rewritten as `[v for v in t if v not in u]`, because the comprehension sees
131
+ an empty `u`. Refuse those.
132
+ """
133
+ pat = re.compile(rf"\b{re.escape(var)}\b")
134
+ return any(pat.search(e) for e in exprs)
135
+
136
+ def _truthiness(self, lines: list[str], idx: int) -> tuple | None:
137
+ line = lines[idx]
138
+ if not self._is_boolean_line(line):
139
+ return None
140
+ new = re.sub(r"len\(([^()]+)\)\s*>\s*0", r"\1", line)
141
+ new = re.sub(r"len\(([^()]+)\)\s*==\s*0", r"not \1", new)
142
+ if new == line:
143
+ return None
144
+ return line, new, "Use truthiness instead of a len() comparison"
145
+
146
+ def _or_chain_to_in(self, lines: list[str], idx: int) -> tuple | None:
147
+ line = lines[idx]
148
+ m = re.match(r"(\s*)(if|elif|while)\s+(.+?):\s*$", line)
149
+ if not m:
150
+ return None
151
+ parts = m.group(3).split(" or ")
152
+ if len(parts) < 2:
153
+ return None
154
+ var, values = None, []
155
+ for part in parts:
156
+ pm = re.match(r"\s*([\w.]+)\s*==\s*(.+?)\s*$", part)
157
+ if not pm or (var is not None and pm.group(1) != var):
158
+ return None
159
+ var = pm.group(1)
160
+ values.append(pm.group(2).strip())
161
+ new = f"{m.group(1)}{m.group(2)} {var} in ({', '.join(values)}):"
162
+ return line, new, "Use 'in' for repeated equality checks"
163
+
164
+ def _simple_list_comp(self, lines: list[str], idx: int) -> tuple | None:
165
+ if self._is_for_append_pattern(lines, idx):
166
+ return self._convert_to_list_comp(lines, idx)
167
+ return None
168
+
169
+ def _filtered_list_comp(self, lines: list[str], idx: int) -> tuple | None:
170
+ if idx + 2 >= len(lines):
171
+ return None
172
+ for_m = re.match(r"for\s+(\w+)\s+in\s+(.+?):", lines[idx].strip())
173
+ if_m = re.match(r"if\s+(.+?):", lines[idx + 1].strip())
174
+ app_m = re.match(r"(\w+)\.append\((.+)\)", lines[idx + 2].strip())
175
+ if not (for_m and if_m and app_m):
176
+ return None
177
+ if self._references(app_m.group(1), if_m.group(1), app_m.group(2), for_m.group(2)):
178
+ return None
179
+ indent = len(lines[idx]) - len(lines[idx].lstrip())
180
+ comp = (
181
+ f"{' ' * indent}{app_m.group(1)} = "
182
+ f"[{app_m.group(2)} for {for_m.group(1)} in {for_m.group(2)} if {if_m.group(1)}]"
183
+ )
184
+ return (
185
+ "\n".join(lines[idx : idx + 3]),
186
+ comp,
187
+ "Convert filtered for-loop to list comprehension",
188
+ )
189
+
190
+ def _dict_comp(self, lines: list[str], idx: int) -> tuple | None:
191
+ if idx + 1 >= len(lines):
192
+ return None
193
+ for_m = re.match(r"for\s+(\w+)\s+in\s+(.+?):", lines[idx].strip())
194
+ assign = re.match(r"(\w+)\[(.+?)\]\s*=\s*(.+)", lines[idx + 1].strip())
195
+ if not (for_m and assign):
196
+ return None
197
+ d = assign.group(1)
198
+ # Only a dict literal supports comprehension rewrite; lists use index assignment too.
199
+ if not any(re.match(r"\s*" + re.escape(d) + r"\s*=\s*\{\}\s*$", ln) for ln in lines[:idx]):
200
+ return None
201
+ indent = len(lines[idx]) - len(lines[idx].lstrip())
202
+ comp = (
203
+ f"{' ' * indent}{d} = "
204
+ f"{{{assign.group(2)}: {assign.group(3)} for {for_m.group(1)} in {for_m.group(2)}}}"
205
+ )
206
+ return f"{lines[idx]}\n{lines[idx + 1]}", comp, "Convert for-loop to dict comprehension"
207
+
208
+ def _compare_to_none(self, lines: list[str], idx: int) -> tuple | None:
209
+ line = lines[idx]
210
+ new = re.sub(r"==\s*None", "is None", line)
211
+ new = re.sub(r"!=\s*None", "is not None", new)
212
+ if new == line:
213
+ return None
214
+ return line, new, "Use 'is'/'is not' to compare with None"
215
+
216
+ def _is_for_append_pattern(self, lines: list[str], idx: int) -> bool:
217
+ if idx + 1 >= len(lines):
218
+ return False
219
+ line = lines[idx].strip()
220
+ if not line.startswith("for "):
221
+ return False
222
+ return ".append(" in lines[idx + 1].strip()
223
+
224
+ def _convert_to_list_comp(self, lines: list[str], idx: int) -> tuple | None:
225
+ for_line = lines[idx]
226
+ append_line = lines[idx + 1].strip() if idx + 1 < len(lines) else ""
227
+ for_match = re.match(r"for\s+(\w+)\s+in\s+(.+?):", for_line.strip())
228
+ if not for_match:
229
+ return None
230
+ append_match = re.match(r"(\w+)\.append\((.+)\)", append_line)
231
+ if not append_match:
232
+ return None
233
+ var, iterable = for_match.group(1), for_match.group(2)
234
+ list_var, expr = append_match.group(1), append_match.group(2)
235
+ if self._references(list_var, expr, iterable):
236
+ return None
237
+ indent = len(for_line) - len(for_line.lstrip())
238
+ list_comp = f"{' ' * indent}{list_var} = [{expr} for {var} in {iterable}]"
239
+ return (
240
+ f"{for_line}\n{append_line}",
241
+ list_comp,
242
+ "Convert for-loop with append to list comprehension",
243
+ )