Skip to content
Open
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
27 changes: 18 additions & 9 deletions refactron/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,18 @@ def main(ctx: click.Context) -> None:


# Register subcommands
# We import them here to ensure they are registered with the main group
# Using a try-except block to allow partial loading during refactoring
# We import them here to ensure they are registered with the main group.
# Each block is isolated so a single broken subcommand — or a broken optional
# dependency it pulls in (e.g. a failed native-library load raising OSError,
# not ImportError) — degrades gracefully instead of taking down the whole CLI.
try:
from refactron.cli.auth import auth, login, logout, telemetry

main.add_command(login)
main.add_command(logout)
main.add_command(auth)
main.add_command(telemetry)
except ImportError:
except Exception:
pass

try:
Expand All @@ -111,7 +113,7 @@ def main(ctx: click.Context) -> None:
main.add_command(metrics)
main.add_command(serve_metrics)
main.add_command(suggest)
except ImportError:
except Exception:
pass

try:
Expand All @@ -121,28 +123,35 @@ def main(ctx: click.Context) -> None:
main.add_command(autofix)
main.add_command(rollback)
main.add_command(document)
except ImportError:
except Exception:
pass

try:
from refactron.cli.verify import verify

main.add_command(verify)
except Exception:
pass

try:
from refactron.cli.patterns import patterns

main.add_command(patterns)
except ImportError:
except Exception:
pass

try:
from refactron.cli.repo import repo

main.add_command(repo)
except ImportError:
except Exception:
pass

try:
from refactron.cli.rag import rag

main.add_command(rag)
except ImportError:
except Exception:
pass

try:
Expand All @@ -151,5 +160,5 @@ def main(ctx: click.Context) -> None:
main.add_command(generate_cicd)
main.add_command(feedback)
main.add_command(init)
except ImportError:
except Exception:
pass
74 changes: 74 additions & 0 deletions refactron/cli/verify.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,77 @@
"""
Refactron CLI - Verification Module.
Command to run the Verification Engine on a code change and emit either a
human-readable report or a stable, machine-readable JSON object for CI gates.
"""

from pathlib import Path
from typing import Optional

import click

from refactron.cli.ui import _auth_banner, console
from refactron.verification.engine import VerificationEngine
from refactron.verification.report import (
format_verification_result,
format_verification_result_json,
)


@click.command()
@click.argument("target", type=click.Path(exists=True, dir_okay=False))
@click.option(
"--against",
"-a",
"candidate",
type=click.Path(exists=True, dir_okay=False),
default=None,
help=(
"Path to the proposed/modified version of TARGET. "
"If omitted, TARGET is verified against itself."
),
)
@click.option(
"--project-root",
type=click.Path(exists=True, file_okay=False),
default=None,
help="Project root used by the test-suite gate. Defaults to the current directory.",
)
@click.option(
"--json",
"as_json",
is_flag=True,
default=False,
help="Emit a stable, machine-readable JSON report instead of formatted text.",
)
def verify(
target: str,
candidate: Optional[str],
project_root: Optional[str],
as_json: bool,
) -> None:
"""
Verify that a code change is safe to apply.

TARGET is the file as it currently lives in the project. With --against,
the proposed new content is checked against it (syntax, import integrity,
test suite). Exit code is 0 when safe to apply, 1 when blocked — and with
--json a versioned JSON object is printed for CI dashboards and bots.
"""
target_path = Path(target)
original = target_path.read_text(encoding="utf-8")
transformed = Path(candidate).read_text(encoding="utf-8") if candidate else original

root = Path(project_root) if project_root else Path.cwd()
engine = VerificationEngine(project_root=root)
result = engine.verify(original, transformed, target_path)

if as_json:
# JSON mode prints only the JSON object so consumers can parse stdout.
click.echo(format_verification_result_json(result))
else:
console.print()
_auth_banner("Verification")
format_verification_result(result, console)
"""refactron verify <file> --against <original> — standalone verification command."""

from pathlib import Path
Expand Down
51 changes: 43 additions & 8 deletions refactron/verification/checks/test_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
Expand All @@ -20,6 +21,8 @@ class TestSuiteGate(BaseCheck):

def __init__(self, project_root: Optional[Path] = None):
self.project_root = project_root
self._test_file_cache: Optional[Dict[str, List[Path]]] = None
self._all_test_files: Optional[List[Path]] = None

def verify(self, original: str, transformed: str, file_path: Path) -> CheckResult:
start = time.monotonic()
Expand Down Expand Up @@ -54,16 +57,30 @@ def verify(self, original: str, transformed: str, file_path: Path) -> CheckResul
# Delete .pyc cache
self._clear_pycache(file_path)

# Run pytest
cmd = ["python3", "-m", "pytest", "-x", "-q"]
# Run pytest from the project root with the host interpreter so
# the repo's pyproject.toml / pytest.ini / conftest.py are picked
# up and the same venv as the host process is used.
run_cwd = (self.project_root or file_path.parent).resolve()

# Make the project root importable for edge cases where the layout
# relies on PYTHONPATH rather than an installed package.
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
existing_pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
os.pathsep.join([str(run_cwd), existing_pythonpath])
if existing_pythonpath
else str(run_cwd)
)

cmd = [sys.executable, "-m", "pytest", "-x", "-q"]
cmd += [str(f) for f in test_files]
result = subprocess.run(
cmd,
timeout=45,
capture_output=True,
text=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
cwd=str(file_path.parent),
env=env,
cwd=str(run_cwd),
)

elapsed = int((time.monotonic() - start) * 1000)
Expand Down Expand Up @@ -110,11 +127,27 @@ def _find_relevant_tests(self, file_path: Path) -> List[Path]:
module_name = file_path.stem
search_root = self.project_root or file_path.parent

if self._test_file_cache is None:
self._test_file_cache = {}
self._all_test_files = []

test_dirs = [d for d in [search_root / "tests", search_root / "test"] if d.is_dir()]
search_dirs = test_dirs if test_dirs else [search_root]
excluded_dirs = {".git", ".rag", "__pycache__", "venv", ".venv", "env", "node_modules"}

for root_dir in search_dirs:
for py_file in root_dir.rglob("*.py"):
if any(excluded in py_file.parts for excluded in excluded_dirs):
continue
name = py_file.name
if name.startswith("test_") or name.endswith("_test.py"):
self._all_test_files.append(py_file)

if module_name in self._test_file_cache:
return self._test_file_cache[module_name]

test_files: List[Path] = []
for py_file in search_root.rglob("*.py"):
name = py_file.name
if not (name.startswith("test_") or name.endswith("_test.py")):
continue
for py_file in self._all_test_files: # type: ignore
if py_file == file_path:
continue
try:
Expand All @@ -123,6 +156,8 @@ def _find_relevant_tests(self, file_path: Path) -> List[Path]:
test_files.append(py_file)
except Exception:
continue

self._test_file_cache[module_name] = test_files
return test_files

@staticmethod
Expand Down
14 changes: 13 additions & 1 deletion refactron/verification/report.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Rich CLI output formatting for VerificationResult."""
"""CLI output formatting for VerificationResult (Rich and JSON)."""

import json

from rich.console import Console

Expand Down Expand Up @@ -29,3 +31,13 @@ def format_verification_result(result: VerificationResult, console: Console) ->
for name, reason in result.skipped_checks:
console.print(f" [dim]- {name}: {reason}[/dim]")
console.print(f"\n [bold red]Blocked.[/bold red] {result.blocking_reason}")


def format_verification_result_json(result: VerificationResult, indent: int = 2) -> str:
"""Render a VerificationResult as a stable, machine-readable JSON string.

Intended for CI gates, bots, and parent tools that need structured data
rather than scraping terminal text. The schema is versioned — see
``VerificationResult.to_json_dict``.
"""
return json.dumps(result.to_json_dict(), indent=indent, sort_keys=True)
40 changes: 40 additions & 0 deletions refactron/verification/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple

# Bumped whenever the JSON shape produced by ``to_json_dict`` changes in a
# way consumers must adapt to. Additive fields do not require a bump.
JSON_SCHEMA_VERSION = 1


@dataclass(frozen=True)
class CheckResult:
Expand All @@ -18,6 +22,17 @@ class CheckResult:
duration_ms: int
details: Dict[str, Any]

def to_json_dict(self) -> Dict[str, Any]:
"""Return a JSON-serializable dict of this check's result."""
return {
"check_name": self.check_name,
"passed": self.passed,
"blocking_reason": self.blocking_reason,
"confidence": self.confidence,
"duration_ms": self.duration_ms,
"details": self.details,
}


@dataclass(frozen=True)
class VerificationResult:
Expand All @@ -39,3 +54,28 @@ class VerificationResult:
confidence_score: float
verification_ms: int
check_results: List[CheckResult]

def to_json_dict(self) -> Dict[str, Any]:
"""Return a stable, JSON-serializable dict of the full result.

The shape is versioned via ``schema_version`` so machine consumers
(CI gates, bots, parent tools) can evolve safely. Adding new keys is
backwards-compatible; removing or renaming keys bumps the version.
"""
return {
"schema_version": JSON_SCHEMA_VERSION,
"status": "safe" if self.safe_to_apply else "blocked",
"safe_to_apply": self.safe_to_apply,
"passed": self.passed,
"blocking_reason": self.blocking_reason,
"confidence_score": self.confidence_score,
"verification_ms": self.verification_ms,
"checks_run": list(self.checks_run),
"checks_passed": list(self.checks_passed),
"checks_failed": list(self.checks_failed),
"skipped_checks": [
{"check_name": name, "reason": reason}
for name, reason in self.skipped_checks
],
"checks": [cr.to_json_dict() for cr in self.check_results],
}
Loading
Loading