Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions refactron/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ def main(ctx: click.Context) -> None:
except ImportError:
pass

try:
from refactron.cli.run import run

main.add_command(run)
except ImportError:
pass

try:
from refactron.cli.cicd import feedback, generate_cicd, init

Expand Down
41 changes: 41 additions & 0 deletions refactron/cli/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""
Refactron CLI - Run Module.
Provides the 'run' command to execute Refactron as a connected pipeline.
"""
from typing import Optional
from pathlib import Path
import click

from refactron.cli.ui import console, _auth_banner
from refactron.cli.utils import _validate_path
from refactron.core.pipeline import RefactronPipeline

@click.command()
@click.argument("target", type=click.Path(exists=True), required=False)
@click.option(
"--incremental/--no-incremental",
default=False,
help="Enable or disable incremental analysis for the pipeline run (off by default)",
)
def run(target: Optional[str], incremental: bool) -> None:
"""
Run Refactron as a connected pipeline session.
"""
console.print()
_auth_banner("Pipeline Run")
console.print()

target_path = _validate_path(target) if target else Path.cwd()

with console.status("[primary]Executing pipeline run...[/primary]"):
try:
pipeline = RefactronPipeline()
result = pipeline.analyze(target_path, use_incremental=incremental)
Comment on lines +28 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Load pipeline config from the requested target project.

RefactronPipeline() defaults config discovery to the current working directory, so refactron run /other/project can ignore /other/project/.refactron.yaml. Pass the effective project root derived from target_path.

Proposed fix
     target_path = _validate_path(target) if target else Path.cwd()
+    project_root = target_path if target_path.is_dir() else target_path.parent
 
     with console.status("[primary]Executing pipeline run...[/primary]"):
         try:
-            pipeline = RefactronPipeline()
+            pipeline = RefactronPipeline(project_root=project_root)
             result = pipeline.analyze(target_path, use_incremental=incremental)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
target_path = _validate_path(target) if target else Path.cwd()
with console.status("[primary]Executing pipeline run...[/primary]"):
try:
pipeline = RefactronPipeline()
result = pipeline.analyze(target_path, use_incremental=incremental)
target_path = _validate_path(target) if target else Path.cwd()
project_root = target_path if target_path.is_dir() else target_path.parent
with console.status("[primary]Executing pipeline run...[/primary]"):
try:
pipeline = RefactronPipeline(project_root=project_root)
result = pipeline.analyze(target_path, use_incremental=incremental)
🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@refactron/cli/run.py` around lines 28 - 33, RefactronPipeline is being
instantiated without the target project's path so config discovery defaults to
the current working directory; change the code that creates RefactronPipeline()
to pass the effective project root derived from target_path (from
_validate_path) — e.g. construct RefactronPipeline with the project root
argument (or call its load/config initializer) so that
pipeline.analyze(target_path, use_incremental=incremental) picks up
/other/project/.refactron.yaml instead of the CWD.


console.print(f"[green]Pipeline analysis completed successfully.[/green]")
console.print(f"Total files analyzed: {result.total_files}")
console.print(f"Total issues found: {result.total_issues}")

except Exception as e:
console.print(f"[red]Pipeline run failed: {e}[/red]")
raise SystemExit(1)
Comment on lines +35 to +41
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the lint failure and preserve exception chaining.

Line 35 is an f-string without placeholders, and the SystemExit raised from the except block should be chained or explicitly suppressed.

Proposed fix
-            console.print(f"[green]Pipeline analysis completed successfully.[/green]")
+            console.print("[green]Pipeline analysis completed successfully.[/green]")
             console.print(f"Total files analyzed: {result.total_files}")
             console.print(f"Total issues found: {result.total_issues}")
             
-        except Exception as e:
-            console.print(f"[red]Pipeline run failed: {e}[/red]")
-            raise SystemExit(1)
+        except Exception as exc:
+            console.print(f"[red]Pipeline run failed: {exc}[/red]")
+            raise SystemExit(1) from exc

As per coding guidelines, **/*.py: “Use line length of 100 characters, enforced by black, isort, and flake8”.

🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] 38-38: flake8 reported F541 f-string is missing placeholders


[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🪛 Ruff (0.15.10)

[error] 35-35: f-string without any placeholders

Remove extraneous f prefix

(F541)


[warning] 39-39: Do not catch blind exception: Exception

(BLE001)


[warning] 41-41: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@refactron/cli/run.py` around lines 35 - 41, The first console.print uses an
f-string with no placeholders (console.print(f"[green]Pipeline analysis
completed successfully.[/green]"))—replace it with a normal string literal to
satisfy linting (console.print("[green]Pipeline analysis completed
successfully.[/green]")), and in the except block preserve exception chaining by
re-raising SystemExit with the caught exception (use raise SystemExit(1) from e)
so the original traceback (e) is kept; ensure lines remain under the
100-character limit.

97 changes: 97 additions & 0 deletions refactron/core/pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Pipeline orchestration for automated and session-based flows."""

from pathlib import Path
from typing import Optional, Union, List

from refactron.core.config import RefactronConfig
from refactron.core.refactron import Refactron
from refactron.core.analysis_result import AnalysisResult
from refactron.core.models import CodeIssue
from refactron.autofix.engine import AutoFixEngine

class RefactronPipeline:
"""Automated pipeline execution for session-based and CI/CD flows."""

def __init__(self, project_root: Optional[Union[str, Path]] = None, config_path: Optional[Union[str, Path]] = None):
Comment on lines +3 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the duplicate imports and formatter failures.

RefactronConfig, Refactron, and AnalysisResult are imported twice, and Line 19 exceeds the 100-character formatter/lint limit. This is already causing black/isort pre-commit failures.

Proposed cleanup
 from pathlib import Path
-from typing import Optional, Union, List
+from typing import Dict, List, Optional, Union
 
+from refactron.autofix.engine import AutoFixEngine
+from refactron.core.analysis_result import AnalysisResult
 from refactron.core.config import RefactronConfig
-from refactron.core.refactron import Refactron
-from refactron.core.analysis_result import AnalysisResult
 from refactron.core.models import CodeIssue
-from refactron.autofix.engine import AutoFixEngine
-
-from refactron.core.config import RefactronConfig
 from refactron.core.refactron import Refactron
-from refactron.core.analysis_result import AnalysisResult
+
 
 class RefactronPipeline:
     """Automated pipeline execution for session-based and CI/CD flows."""
 
-    def __init__(self, project_root: Optional[Union[str, Path]] = None, config_path: Optional[Union[str, Path]] = None):
+    def __init__(
+        self,
+        project_root: Optional[Union[str, Path]] = None,
+        config_path: Optional[Union[str, Path]] = None,
+    ):

As per coding guidelines, **/*.py: “Use line length of 100 characters, enforced by black, isort, and flake8”.

🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@refactron/core/pipeline.py` around lines 3 - 19, Remove the duplicated
imports of RefactronConfig, Refactron, and AnalysisResult (keep a single import
line for each unique symbol) and update the RefactronPipeline.__init__ signature
so it does not exceed the 100-character line limit (wrap parameters across
multiple lines or use type aliases for Optional[Union[str, Path]]/config_path)
to satisfy black/isort/flake8 formatting rules.

"""
Initialize the pipeline.

Args:
project_root: Optional root directory of the project.
config_path: Optional explicit configuration path.
"""
self.project_root = Path(project_root) if project_root else None
self.config_path = Path(config_path) if config_path else None

self._config = self._load_config()
# Enforce pipeline default behavior (full scan) unless overridden later
self._config.enable_incremental_analysis = False
self.refactron = Refactron(self._config)
self.autofix_engine = AutoFixEngine()
self._fixer_cache = {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Annotate _fixer_cache to unblock mypy.

The untyped empty dict makes reads return Any, which is why mypy reports both the missing annotation and the Optional[str] return mismatch.

Proposed fix
-        self._fixer_cache = {}
+        self._fixer_cache: Dict[str, Optional[str]] = {}

As per coding guidelines, refactron/**/*.py: “Type annotations are required in refactron/ with mypy disallow_untyped_defs = true enabled”.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self._fixer_cache = {}
self._fixer_cache: Dict[str, Optional[str]] = {}
🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@refactron/core/pipeline.py` at line 35, The attribute _fixer_cache is untyped
which yields Any; annotate it as a mapping of fixer keys to optional strings
(e.g. Dict[str, Optional[str]]) to satisfy mypy and the Optional[str] return
expectations: change the assignment in the class from self._fixer_cache = {} to
self._fixer_cache: Dict[str, Optional[str]] = {} and add the necessary typing
imports (from typing import Dict, Optional) at the module top if not present.


def _load_config(self) -> RefactronConfig:
if self.config_path and self.config_path.exists():
return RefactronConfig.from_file(self.config_path)

root = self.project_root or Path.cwd()
yaml_path = root / ".refactron.yaml"
if yaml_path.exists():
return RefactronConfig.from_file(yaml_path)
return RefactronConfig.default()

def analyze(self, target: Union[str, Path], use_incremental: Optional[bool] = None) -> AnalysisResult:
"""
Run analysis as part of a pipeline flow.

Pipeline-only overrides:
- `enable_incremental_analysis` is forced to False by default to guarantee
a fully fresh, reproducible analysis in CI/CD or pipeline environments.

Args:
target: Path to file or directory to analyze.
use_incremental: Optional override to enable incremental analysis.

Returns:
AnalysisResult containing issues and metrics.
"""
if use_incremental is not None:
self.refactron.config.enable_incremental_analysis = use_incremental

Comment on lines +58 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply incremental overrides to the initialized tracker, not only the config.

Refactron constructs IncrementalAnalysisTracker(enabled=self.config.enable_incremental_analysis) during initialization, so changing only self.refactron.config.enable_incremental_analysis here can leave the actual tracker running with the old mode. Rebuild Refactron or update the tracker state when use_incremental changes.

Possible fix
         if use_incremental is not None:
-            self.refactron.config.enable_incremental_analysis = use_incremental
+            self._config.enable_incremental_analysis = use_incremental
+            self.refactron.config.enable_incremental_analysis = use_incremental
+            self.refactron.incremental_tracker.enabled = use_incremental
🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@refactron/core/pipeline.py` around lines 62 - 64, The code updates only
self.refactron.config.enable_incremental_analysis when use_incremental is
provided but leaves the live tracker unchanged; update the actual
IncrementalAnalysisTracker on the Refactron instance as well. After setting
self.refactron.config.enable_incremental_analysis, either set the existing
tracker’s enabled state (e.g. self.refactron.incremental_tracker.enabled =
use_incremental or call its setter method) or recreate it:
self.refactron.incremental_tracker =
IncrementalAnalysisTracker(enabled=use_incremental) (import
IncrementalAnalysisTracker if needed) so the running tracker matches the new
config.

return self.refactron.analyze(target)

def queue_issues(self, issues: List[CodeIssue]) -> list:
"""Queue issues mapped to their responsible fixers."""
queued = []
for issue in issues:
fixer_name = self._find_fixer_name(issue)
if fixer_name:
queued.append({"issue": issue, "fixer_name": fixer_name})
return queued

def _find_fixer_name(self, issue: CodeIssue) -> Optional[str]:
"""Find the appropriate fixer name for a given issue."""
# Use rule_id or category as cache key
issue_type = issue.rule_id or str(getattr(issue, 'category', type(issue).__name__))
if issue_type in self._fixer_cache:
return self._fixer_cache[issue_type]

candidate_name = None

# 1. Ask AutoFixEngine directly without preview (O(1) dictionary lookup)
if hasattr(self, 'autofix_engine') and self.autofix_engine.can_fix(issue):
candidate_name = issue.rule_id
else:
# 2. Fallback to preview-based resolution for ambiguous cases
if hasattr(self, 'autofix_engine'):
for name, fixer in self.autofix_engine.fixers.items():
try:
preview_result = fixer.preview(issue, "x = 1\n")
if preview_result and getattr(preview_result, "success", False):
candidate_name = name
break
except Exception:
continue

self._fixer_cache[issue_type] = candidate_name
Comment on lines +74 to +96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid caching fallback matches by broad category only.

When rule_id is missing, the cache key collapses to the issue category, so one successful preview for a STYLE issue can assign the same fixer to unrelated STYLE issues without re-evaluating them. Either skip caching for rule-less issues or include more issue-specific fields in the fallback key.

Safer cache key option
-        issue_type = issue.rule_id or str(getattr(issue, 'category', type(issue).__name__))
+        if issue.rule_id:
+            issue_type = issue.rule_id
+        else:
+            issue_type = ":".join(
+                [
+                    str(getattr(issue, "category", type(issue).__name__)),
+                    str(getattr(issue, "message", "")),
+                ]
+            )
🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] 84-84: mypy error: Returning Any from function declared to return "Optional[str]" [no-any-return]


[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🪛 Ruff (0.15.10)

[error] 97-98: try-except-continue detected, consider logging the exception

(S112)


[warning] 97-97: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@refactron/core/pipeline.py` around lines 78 - 100, The current caching uses
issue_type = issue.rule_id or str(getattr(issue, 'category', ...)) which causes
fallback preview matches to be cached broadly; change the logic in the fixer
resolution in resolve/fixer lookup so that when issue.rule_id is missing you
either (a) do not write the fallback candidate_name into self._fixer_cache for
that issue_type, or (b) make the cache key more specific by including additional
issue-specific fields (e.g., a unique identifier, file/line, or a short snippet
from issue.source/statement) before assigning self._fixer_cache[issue_type] =
candidate_name; ensure this applies only when using the preview-based loop over
self.autofix_engine.fixers and keep the fast-path autofix_engine.can_fix(issue)
caching unchanged.

return candidate_name
107 changes: 107 additions & 0 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for the Refactron pipeline module."""

import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock

from refactron.core.pipeline import RefactronPipeline
from refactron.core.config import RefactronConfig

def test_pipeline_loads_project_config():
"""Test that RefactronPipeline loads configuration from the project root."""
with tempfile.TemporaryDirectory() as temp_dir:
root_path = Path(temp_dir)

config_path = root_path / ".refactron.yaml"
config_path.write_text("max_function_complexity: 99\nenable_metrics: false\n")

target_file = root_path / "dummy.py"
target_file.write_text("def foo():\n pass\n")

with patch("refactron.core.pipeline.Refactron.analyze") as mock_analyze:
mock_analyze.return_value = "mock_result"
pipeline = RefactronPipeline(project_root=root_path)

result = pipeline.analyze(target_file)

assert result == "mock_result"
config_passed = pipeline.refactron.config

assert isinstance(config_passed, RefactronConfig)
assert config_passed.max_function_complexity == 99
assert config_passed.enable_metrics is False
assert config_passed.enable_incremental_analysis is False

def test_pipeline_incremental_override():
"""Test that RefactronPipeline can override the incremental setting."""
with tempfile.TemporaryDirectory() as temp_dir:
root_path = Path(temp_dir)

target_file = root_path / "dummy.py"
target_file.write_text("def foo():\n pass\n")

with patch("refactron.core.pipeline.Refactron.analyze"):
pipeline = RefactronPipeline(project_root=root_path)
pipeline.analyze(target_file, use_incremental=True)

config_passed = pipeline.refactron.config
assert config_passed.enable_incremental_analysis is True

def test_pipeline_queue_issues_caching():
"""Test that queue_issues leverages caching and direct mapping without multiple previews."""
from refactron.core.models import CodeIssue, IssueCategory, IssueLevel
from refactron.autofix.models import FixResult
from pathlib import Path

with tempfile.TemporaryDirectory() as temp_dir:
pipeline = RefactronPipeline(project_root=Path(temp_dir))

# Mock AutoFixEngine on the pipeline directly
pipeline.autofix_engine = MagicMock()

calls = {"preview": 0}

# Create a mock fixer that counts preview calls
mock_fixer = MagicMock()
mock_fixer.name = "mock_rule"
def mock_preview(issue, code):
calls["preview"] += 1
return FixResult(success=True, reason="")
mock_fixer.preview.side_effect = mock_preview

pipeline.autofix_engine.fixers = {"mock_rule": mock_fixer}

# Mock can_fix to return False so it falls back to preview ONCE per type
pipeline.autofix_engine.can_fix.return_value = False

issue1 = CodeIssue(
category=IssueCategory.STYLE,
level=IssueLevel.WARNING,
message="Test issue",
file_path=Path("dummy.py"),
line_number=1,
rule_id="unknown_rule_1"
)
issue2 = CodeIssue(
category=IssueCategory.STYLE,
level=IssueLevel.WARNING,
message="Test issue 2",
file_path=Path("dummy.py"),
line_number=2,
rule_id="unknown_rule_1"
)
issue3 = CodeIssue(
category=IssueCategory.STYLE,
level=IssueLevel.WARNING,
message="Test issue 3",
file_path=Path("dummy.py"),
line_number=3,
rule_id="unknown_rule_1"
)

queued = pipeline.queue_issues([issue1, issue2, issue3])

assert len(queued) == 3
# Should only have run preview 1 time, because the subsequent issues have the same rule_id and hit the cache.
assert calls["preview"] == 1
Comment on lines +105 to +106
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrap the long comment to satisfy flake8.

Line 105 exceeds the configured 100-character limit and is already failing pre-commit.

Proposed fix
-        # Should only have run preview 1 time, because the subsequent issues have the same rule_id and hit the cache.
+        # Should only have run preview 1 time because subsequent issues have the
+        # same rule_id and hit the cache.

As per coding guidelines, **/*.py: “Flake8 linting must use max-line-length of 100”.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Should only have run preview 1 time, because the subsequent issues have the same rule_id and hit the cache.
assert calls["preview"] == 1
# Should only have run preview 1 time because subsequent issues have the
# same rule_id and hit the cache.
assert calls["preview"] == 1
🧰 Tools
🪛 GitHub Actions: Pre-commit

[error] pre-commit hook "black" failed (files were reformatted by the hook)


[error] pre-commit hook "isort" failed (files were modified/fixed by the hook)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_pipeline.py` around lines 105 - 106, The inline comment above the
assertion in tests/test_pipeline.py is longer than 100 characters; split or
reflow that comment into multiple shorter lines (each <=100 chars) so it
complies with flake8 max-line-length. Locate the long comment that precedes the
assertion using the assertion snippet calls["preview"] == 1 (or the surrounding
test function in test_pipeline.py) and break the sentence into two or more
shorter comment lines or rephrase it to keep each line under the 100-character
limit.

assert pipeline._fixer_cache["unknown_rule_1"] == "mock_rule"
Loading