From 0278c58210d0b345b86563a051bc4f584ac9fde6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 04:11:35 +0000 Subject: [PATCH] feat(fixers): add Go module and Cargo crate static fixers Static fixers only resolved missing pip and npm dependencies, so go-gin and rust-axum verify-loop failures for absent dependencies always paid an LLM retry. Add MissingGoModuleFixer (go get) and MissingCargoCrateFixer (cargo add), mirroring the proven pip/npm contract: parse the package from the failure log, validate it, and let go/cargo update the manifest. Crate and module name validation rejects stdlib and unsafe paths so a bare standard-library name is never shipped to the package manager. Refs #57 https://claude.ai/code/session_01U5UiaXEjzHDaeKyCvSeVtJ --- simplicio/pipeline_fixers.py | 75 +++++++++++++++++++++ tests/python/test_pipeline_fixers.py | 97 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/simplicio/pipeline_fixers.py b/simplicio/pipeline_fixers.py index 618ee9c..f759d59 100644 --- a/simplicio/pipeline_fixers.py +++ b/simplicio/pipeline_fixers.py @@ -69,6 +69,32 @@ def _safe_node_package(raw: str) -> str | None: return None +def _safe_go_module(raw: str) -> str | None: + spec = raw.strip().strip('"').strip("'") + if not spec or spec.startswith((".", "/", "\\")): + return None + # Go import paths look like host/owner/repo; require a dot or slash so a + # bare standard-library name (already shipped with the toolchain) is never + # passed to `go get`. + if "." not in spec.split("/", 1)[0] and "/" not in spec: + return None + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._~/-]*", spec): + return spec + return None + + +_RUST_STD_MODULES = {"crate", "self", "super", "std", "core", "alloc"} + + +def _safe_cargo_crate(raw: str) -> str | None: + candidate = raw.strip().strip("`") + if not candidate or candidate in _RUST_STD_MODULES: + return None + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", candidate): + return candidate + return None + + def _dependency_name(spec: str) -> str: name = re.split(r"\s*(?:[<>=!~]=?|;|\[)", spec.strip(), maxsplit=1)[0] return name.replace("_", "-").lower() @@ -220,6 +246,53 @@ def _package_install_command(package_manager: str, package: str) -> list[str]: return [package_manager, "add", package] +class MissingGoModuleFixer(StaticFixer): + name = "missing-go-module" + pattern = re.compile( + r"(?:no required module provides package|cannot find package)\s+[\"']?" + r"([A-Za-z0-9][A-Za-z0-9._~/-]*)", + re.I, + ) + + def try_fix(self, log: str, project_dir: Path, runner: Runner | None = None) -> FixerResult: + match = self.pattern.search(log or "") + module = _safe_go_module(match.group(1)) if match else None + if not module: + return FixerResult(self.name, False, "no safe Go module found") + + try: + result = _run(["go", "get", module], project_dir, runner) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + return FixerResult(self.name, False, f"go get failed: {exc}") + if not _ok(result): + tail = ((result.stdout or "") + (result.stderr or ""))[-400:] + return FixerResult(self.name, False, f"go get {module} failed: {tail}") + return FixerResult(self.name, True, f"go get {module} (go.mod updated)") + + +class MissingCargoCrateFixer(StaticFixer): + name = "missing-cargo-crate" + pattern = re.compile( + r"(?:use of undeclared crate or module|maybe a missing crate)\s+`([A-Za-z0-9_][A-Za-z0-9_-]*)`", + re.I, + ) + + def try_fix(self, log: str, project_dir: Path, runner: Runner | None = None) -> FixerResult: + match = self.pattern.search(log or "") + crate = _safe_cargo_crate(match.group(1)) if match else None + if not crate: + return FixerResult(self.name, False, "no safe Cargo crate found") + + try: + result = _run(["cargo", "add", crate], project_dir, runner) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + return FixerResult(self.name, False, f"cargo add failed: {exc}") + if not _ok(result): + tail = ((result.stdout or "") + (result.stderr or ""))[-400:] + return FixerResult(self.name, False, f"cargo add {crate} failed: {tail}") + return FixerResult(self.name, True, f"cargo add {crate} (Cargo.toml updated)") + + class RuffFormatFixer(StaticFixer): name = "ruff-format" pattern = re.compile(r"\b(?:SyntaxError|IndentationError)\b", re.I) @@ -271,6 +344,8 @@ def _python_error_target(log: str, project_dir: Path) -> Path | None: STATIC_FIXERS: list[StaticFixer] = [ MissingPipPackageFixer(), MissingNpmPackageFixer(), + MissingGoModuleFixer(), + MissingCargoCrateFixer(), RuffFormatFixer(), ] diff --git a/tests/python/test_pipeline_fixers.py b/tests/python/test_pipeline_fixers.py index 93496e1..899a4a1 100644 --- a/tests/python/test_pipeline_fixers.py +++ b/tests/python/test_pipeline_fixers.py @@ -2,6 +2,8 @@ import sys from simplicio.pipeline_fixers import ( + MissingCargoCrateFixer, + MissingGoModuleFixer, MissingNpmPackageFixer, MissingPipPackageFixer, RuffFormatFixer, @@ -101,6 +103,101 @@ def fake_run(argv, **kwargs): assert " print" in target.read_text(encoding="utf-8") +def test_missing_go_module_fixer_runs_go_get(tmp_path): + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + return _ok(argv) + + result = MissingGoModuleFixer().try_fix( + "main.go:5:2: no required module provides package " + "github.com/gin-gonic/gin; to add it:", + tmp_path, + runner=fake_run, + ) + + assert result.applied is True + assert result.fixer == "missing-go-module" + assert calls[0][0] == ["go", "get", "github.com/gin-gonic/gin"] + + +def test_missing_go_module_fixer_rejects_stdlib_and_unsafe(tmp_path): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return _ok(argv) + + result = MissingGoModuleFixer().try_fix( + 'cannot find package "../secret"', + tmp_path, + runner=fake_run, + ) + + assert result.applied is False + assert calls == [] + + +def test_missing_cargo_crate_fixer_runs_cargo_add(tmp_path): + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + return _ok(argv) + + result = MissingCargoCrateFixer().try_fix( + "error[E0433]: failed to resolve: use of undeclared crate or module `serde_json`", + tmp_path, + runner=fake_run, + ) + + assert result.applied is True + assert result.fixer == "missing-cargo-crate" + assert calls[0][0] == ["cargo", "add", "serde_json"] + + +def test_missing_cargo_crate_fixer_rejects_std_module(tmp_path): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return _ok(argv) + + result = MissingCargoCrateFixer().try_fix( + "error[E0433]: failed to resolve: use of undeclared crate or module `std`", + tmp_path, + runner=fake_run, + ) + + assert result.applied is False + assert calls == [] + + +def test_try_static_fixers_dispatches_go_and_cargo(tmp_path): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return _ok(argv) + + go_result = try_static_fixers( + "no required module provides package github.com/google/uuid", + tmp_path, + runner=fake_run, + ) + cargo_result = try_static_fixers( + "use of undeclared crate or module `tokio`", + tmp_path, + runner=fake_run, + ) + + assert go_result.fixer == "missing-go-module" + assert cargo_result.fixer == "missing-cargo-crate" + assert ["go", "get", "github.com/google/uuid"] in calls + assert ["cargo", "add", "tokio"] in calls + + def test_try_static_fixers_returns_clear_no_match(tmp_path): result = try_static_fixers("AssertionError: expected 200 got 201", tmp_path)