-
Notifications
You must be signed in to change notification settings - Fork 4
Feature/mvp phase2 verification #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6ed08ac
bf1bb88
14d3412
0a7550b
8bd9fd7
48be02c
db11a26
6dce495
f722bb3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -145,6 +145,7 @@ def fix_file( | |||||||||||||||||||
| file_path: Path, | ||||||||||||||||||||
| issues: List[CodeIssue], | ||||||||||||||||||||
| dry_run: bool = True, | ||||||||||||||||||||
| verify: bool = False, | ||||||||||||||||||||
| ) -> Tuple[str, Optional[str]]: | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| Apply all fixable issues to a file. | ||||||||||||||||||||
|
|
@@ -153,10 +154,15 @@ def fix_file( | |||||||||||||||||||
| unified diff are returned for display only. In dry_run=False mode the | ||||||||||||||||||||
| fixed content is written atomically (temp-file → os.replace). | ||||||||||||||||||||
|
|
||||||||||||||||||||
| When verify=True the VerificationEngine is invoked after generating | ||||||||||||||||||||
| the diff. If verification blocks the transform, (original_code, None) | ||||||||||||||||||||
| is returned and no bytes are written. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Args: | ||||||||||||||||||||
| file_path: Path to the Python file to fix. | ||||||||||||||||||||
| issues: List of CodeIssue objects to attempt to fix. | ||||||||||||||||||||
| dry_run: When True, no bytes are written to disk. | ||||||||||||||||||||
| verify: When True, run VerificationEngine before writing. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Returns: | ||||||||||||||||||||
| Tuple of (fixed_code, diff). diff is None/empty when no changes | ||||||||||||||||||||
|
|
@@ -176,6 +182,19 @@ def fix_file( | |||||||||||||||||||
|
|
||||||||||||||||||||
| diff = generate_diff(code, current_code, file_path.name) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Verification gate | ||||||||||||||||||||
| if verify and current_code != code: | ||||||||||||||||||||
| import logging | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from refactron.verification import VerificationEngine | ||||||||||||||||||||
|
|
||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||
| ve = VerificationEngine(project_root=file_path.parent) | ||||||||||||||||||||
| vr = ve.verify(code, current_code, file_path) | ||||||||||||||||||||
|
Comment on lines
+192
to
+193
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verification root is too narrow; Line 192 sets Proposed fix- ve = VerificationEngine(project_root=file_path.parent)
+ project_root = file_path.parent
+ for parent in [file_path.parent, *file_path.parents]:
+ if (parent / ".git").exists() or (parent / ".refactron.yaml").exists():
+ project_root = parent
+ break
+ ve = VerificationEngine(project_root=project_root)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| if not vr.safe_to_apply: | ||||||||||||||||||||
| logger.warning("Verification blocked %s: %s", file_path, vr.blocking_reason) | ||||||||||||||||||||
| return code, None | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if not dry_run and current_code != code: | ||||||||||||||||||||
| # Atomic write: temp file in same directory → os.replace | ||||||||||||||||||||
| tmp_fd, tmp_path = tempfile.mkstemp( | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| """Verification Engine — proves code transforms are safe before writing.""" | ||
|
|
||
| from refactron.verification.checks import ImportIntegrityVerifier, SyntaxVerifier, TestSuiteGate | ||
| from refactron.verification.engine import BaseCheck, VerificationEngine | ||
| from refactron.verification.result import CheckResult, VerificationResult | ||
|
|
||
| __all__ = [ | ||
| "BaseCheck", | ||
| "CheckResult", | ||
| "ImportIntegrityVerifier", | ||
| "SyntaxVerifier", | ||
| "TestSuiteGate", | ||
| "VerificationEngine", | ||
| "VerificationResult", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| """Verification checks package.""" | ||
|
|
||
| from refactron.verification.checks.imports import ImportIntegrityVerifier | ||
| from refactron.verification.checks.syntax import SyntaxVerifier | ||
| from refactron.verification.checks.test_gate import TestSuiteGate | ||
|
|
||
| __all__ = ["ImportIntegrityVerifier", "SyntaxVerifier", "TestSuiteGate"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| """ImportIntegrityVerifier — Check 2: import removal, resolution, cycle detection.""" | ||
|
|
||
| import ast | ||
| import importlib.util | ||
| import time | ||
| from pathlib import Path | ||
| from typing import Any, Dict, Set | ||
|
|
||
| from refactron.verification.engine import BaseCheck | ||
| from refactron.verification.result import CheckResult | ||
|
|
||
|
|
||
| class ImportIntegrityVerifier(BaseCheck): | ||
| """Validates import integrity after a transform.""" | ||
|
|
||
| name = "import_integrity" | ||
|
|
||
| def verify(self, original: str, transformed: str, file_path: Path) -> CheckResult: | ||
| start = time.monotonic() | ||
| details: Dict[str, Any] = {} | ||
|
|
||
| orig_imports = self._extract_import_names(original) | ||
| trans_imports = self._extract_import_names(transformed) | ||
|
|
||
| # Step 1-2: removed imports still referenced | ||
| removed = orig_imports - trans_imports | ||
| if removed: | ||
| still_used = self._find_references(transformed, removed) | ||
| if still_used: | ||
| names = ", ".join(sorted(still_used)) | ||
| details["removed_but_used"] = sorted(still_used) | ||
| return self._fail( | ||
| f"Import(s) removed but still used: {names}", | ||
| start, | ||
| details, | ||
| ) | ||
|
|
||
| # Step 3: new imports resolvable | ||
| # TODO: Step 4 (cycle detection) deferred — requires project-wide import graph | ||
| # which is expensive for MVP. Will add in Phase 3 if needed. | ||
| added = trans_imports - orig_imports | ||
| if added: | ||
| orig_modules = self._extract_module_names(original) | ||
| trans_modules = self._extract_module_names(transformed) | ||
| new_modules = trans_modules - orig_modules | ||
| for mod in new_modules: | ||
| top_level = mod.split(".")[0] | ||
| if importlib.util.find_spec(top_level) is None: | ||
| details["unresolvable_import"] = mod | ||
|
Comment on lines
+46
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
python - <<'PY'
import importlib.util
print("top-level xml:", importlib.util.find_spec("xml") is not None)
print("missing xml submodule:", importlib.util.find_spec("xml.not_a_real_submodule") is None)
PYRepository: Refactron-ai/Refactron_lib Length of output: 117 🏁 Script executed: cat -n refactron/verification/checks/imports.py | sed -n '40,115p'Repository: Refactron-ai/Refactron_lib Length of output: 3416 Check the full module path instead of just the top-level package, and exclude relative imports Line 48 checks only the top-level package, so imports like Required fixes for mod in new_modules:
- top_level = mod.split(".")[0]
- if importlib.util.find_spec(top_level) is None:
+ if importlib.util.find_spec(mod) is None:
details["unresolvable_import"] = mod- elif isinstance(node, ast.ImportFrom) and node.module:
+ elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
modules.add(node.module)🤖 Prompt for AI Agents |
||
| return self._fail( | ||
| f"New import '{mod}' cannot be resolved", | ||
| start, | ||
| details, | ||
| ) | ||
|
|
||
| elapsed = int((time.monotonic() - start) * 1000) | ||
| return CheckResult( | ||
| check_name=self.name, | ||
| passed=True, | ||
| blocking_reason="", | ||
| confidence=1.0, | ||
| duration_ms=elapsed, | ||
| details=details, | ||
| ) | ||
|
|
||
| def _fail(self, reason: str, start: float, details: Dict[str, Any]) -> CheckResult: | ||
| elapsed = int((time.monotonic() - start) * 1000) | ||
| return CheckResult( | ||
| check_name=self.name, | ||
| passed=False, | ||
| blocking_reason=reason, | ||
| confidence=0.0, | ||
| duration_ms=elapsed, | ||
| details=details, | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _extract_import_names(code: str) -> Set[str]: | ||
| """Extract all locally-bound names from import statements.""" | ||
| names: Set[str] = set() | ||
| try: | ||
| tree = ast.parse(code) | ||
| except SyntaxError: | ||
| return names | ||
| for node in ast.walk(tree): | ||
| if isinstance(node, ast.Import): | ||
| for alias in node.names: | ||
| names.add(alias.asname or alias.name) | ||
| elif isinstance(node, ast.ImportFrom): | ||
| for alias in node.names: | ||
| names.add(alias.asname or alias.name) | ||
| return names | ||
|
Comment on lines
+86
to
+92
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle dotted On Line 88, Suggested fix if isinstance(node, ast.Import):
for alias in node.names:
- names.add(alias.asname or alias.name)
+ names.add(alias.asname or alias.name.split(".")[0])🤖 Prompt for AI Agents |
||
|
|
||
| @staticmethod | ||
| def _extract_module_names(code: str) -> Set[str]: | ||
| """Extract actual module paths from import statements.""" | ||
| modules: Set[str] = set() | ||
| try: | ||
| tree = ast.parse(code) | ||
| except SyntaxError: | ||
| return modules | ||
| for node in ast.walk(tree): | ||
| if isinstance(node, ast.Import): | ||
| for alias in node.names: | ||
| modules.add(alias.name) | ||
| elif isinstance(node, ast.ImportFrom) and node.module: | ||
| modules.add(node.module) | ||
| return modules | ||
|
|
||
| @staticmethod | ||
| def _find_references(code: str, names: Set[str]) -> Set[str]: | ||
| """Find which of the given names are still referenced in code body.""" | ||
| referenced: Set[str] = set() | ||
| try: | ||
| tree = ast.parse(code) | ||
| except SyntaxError: | ||
| return referenced | ||
|
|
||
| for node in ast.walk(tree): | ||
| # Skip import nodes themselves | ||
| if isinstance(node, (ast.Import, ast.ImportFrom)): | ||
| continue | ||
| if isinstance(node, ast.Name) and node.id in names: | ||
| referenced.add(node.id) | ||
| elif isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): | ||
| if node.value.id in names: | ||
| referenced.add(node.value.id) | ||
| return referenced | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """SyntaxVerifier — Check 1: syntax validation, CST roundtrip, dangerous calls.""" | ||
|
|
||
| import ast | ||
| import time | ||
| from pathlib import Path | ||
| from typing import Any, Dict, Set | ||
|
|
||
| from refactron.verification.engine import BaseCheck | ||
| from refactron.verification.result import CheckResult | ||
|
|
||
|
|
||
| class SyntaxVerifier(BaseCheck): | ||
| """Validates that transformed code has valid syntax and no new dangerous calls.""" | ||
|
|
||
| name = "syntax" | ||
|
|
||
| def verify(self, original: str, transformed: str, file_path: Path) -> CheckResult: | ||
| start = time.monotonic() | ||
| details: Dict[str, Any] = {} | ||
|
|
||
| # Step 1: ast.parse | ||
| try: | ||
| ast.parse(transformed, filename=str(file_path)) | ||
| except SyntaxError as e: | ||
| return self._fail( | ||
| f"SyntaxError: {e.msg} (line {e.lineno})", | ||
| start, | ||
| details, | ||
| ) | ||
|
|
||
| # Step 2: libcst roundtrip | ||
| try: | ||
| import libcst | ||
|
|
||
| tree = libcst.parse_module(transformed) | ||
| roundtripped = tree.code | ||
| libcst.parse_module(roundtripped) | ||
| except Exception as e: | ||
| return self._fail( | ||
| f"CST round-trip failed: {e}", | ||
| start, | ||
| details, | ||
| ) | ||
|
Comment on lines
+31
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't downgrade unexpected verifier failures into ordinary check failures. This 🧰 Tools🪛 Ruff (0.15.7)[warning] 38-38: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
|
|
||
| # Step 3: new dangerous calls | ||
| original_calls = self._find_dangerous_calls(original) | ||
| transformed_calls = self._find_dangerous_calls(transformed) | ||
| new_calls = transformed_calls - original_calls | ||
| if new_calls: | ||
| details["new_dangerous_calls"] = sorted(new_calls) | ||
| return self._fail( | ||
| f"New dangerous call(s) introduced: {', '.join(sorted(new_calls))}", | ||
| start, | ||
| details, | ||
| ) | ||
|
Comment on lines
+45
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This only detects new dangerous call types, not new call sites.
Also applies to: 84-104 🤖 Prompt for AI Agents |
||
|
|
||
| # Step 4: import count comparison (full reference check is in ImportIntegrityVerifier) | ||
| orig_import_count = self._count_imports(original) | ||
| trans_import_count = self._count_imports(transformed) | ||
| if trans_import_count < orig_import_count: | ||
| details["imports_removed"] = orig_import_count - trans_import_count | ||
|
|
||
| elapsed = int((time.monotonic() - start) * 1000) | ||
| return CheckResult( | ||
| check_name=self.name, | ||
| passed=True, | ||
| blocking_reason="", | ||
| confidence=1.0, | ||
| duration_ms=elapsed, | ||
| details=details, | ||
| ) | ||
|
|
||
| def _fail(self, reason: str, start: float, details: Dict[str, Any]) -> CheckResult: | ||
| elapsed = int((time.monotonic() - start) * 1000) | ||
| return CheckResult( | ||
| check_name=self.name, | ||
| passed=False, | ||
| blocking_reason=reason, | ||
| confidence=0.0, | ||
| duration_ms=elapsed, | ||
| details=details, | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _find_dangerous_calls(code: str) -> Set[str]: | ||
| """Find all calls to eval/exec/os.system in the code.""" | ||
| dangerous: Set[str] = set() | ||
| try: | ||
| tree = ast.parse(code) | ||
| except SyntaxError: | ||
| return dangerous | ||
|
|
||
| for node in ast.walk(tree): | ||
| if isinstance(node, ast.Call): | ||
| if isinstance(node.func, ast.Name) and node.func.id in ("eval", "exec"): | ||
| dangerous.add(node.func.id) | ||
| elif isinstance(node.func, ast.Attribute): | ||
| if ( | ||
| isinstance(node.func.value, ast.Name) | ||
| and node.func.value.id == "os" | ||
| and node.func.attr == "system" | ||
| ): | ||
| dangerous.add("os.system") | ||
| return dangerous | ||
|
|
||
| @staticmethod | ||
| def _count_imports(code: str) -> int: | ||
| """Count import statements in code.""" | ||
| count = 0 | ||
| try: | ||
| tree = ast.parse(code) | ||
| except SyntaxError: | ||
| return 0 | ||
| for node in ast.walk(tree): | ||
| if isinstance(node, (ast.Import, ast.ImportFrom)): | ||
| count += 1 | ||
| return count | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
VerificationEngineis instantiated withproject_root=file_path.parent, which is usually the module directory, not the repository root. This can causeTestSuiteGateto miss relevant tests (or treat “no tests found” as a pass), weakening verification. Consider passing a true project root (workspace root) or making it a required/configured parameter for verification.