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 +3 -0
- reducio/__main__.py +4 -0
- reducio/agents/__init__.py +17 -0
- reducio/agents/analyzer.py +17 -0
- reducio/agents/base.py +138 -0
- reducio/agents/deduplicator.py +194 -0
- reducio/agents/idiomatizer.py +243 -0
- reducio/agents/pattern.py +253 -0
- reducio/agents/quality_checker.py +309 -0
- reducio/analysis.py +88 -0
- reducio/cli.py +548 -0
- reducio/compare.py +190 -0
- reducio/config.py +67 -0
- reducio/diff.py +75 -0
- reducio/embeddings/__init__.py +16 -0
- reducio/embeddings/service.py +241 -0
- reducio/git_safety.py +56 -0
- reducio/llm/__init__.py +5 -0
- reducio/llm/router.py +148 -0
- reducio/metrics.py +218 -0
- reducio/models.py +263 -0
- reducio/parse.py +94 -0
- reducio/plan_review.py +133 -0
- reducio/progress.py +69 -0
- reducio/repo.py +110 -0
- reducio/reporter.py +126 -0
- reducio/runner.py +52 -0
- reducio/services.py +177 -0
- reducio/session.py +289 -0
- reducio/storage.py +42 -0
- reducio/utils/__init__.py +5 -0
- reducio/utils/code_utils.py +45 -0
- reducio/visual_report.py +497 -0
- reducio/workspace.py +204 -0
- reducio-1.0.0.dist-info/METADATA +54 -0
- reducio-1.0.0.dist-info/RECORD +39 -0
- reducio-1.0.0.dist-info/WHEEL +4 -0
- reducio-1.0.0.dist-info/entry_points.txt +2 -0
- reducio-1.0.0.dist-info/licenses/LICENSE +21 -0
reducio/__init__.py
ADDED
reducio/__main__.py
ADDED
|
@@ -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
|
+
)
|