feat: initial commit

This commit is contained in:
2026-08-29 13:17:59 +02:00
commit 142f5f5759
91 changed files with 6155 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import os
import shutil
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURE = REPO_ROOT / "examples" / "fixtures" / "acme-app"
sys.path.insert(0, str(REPO_ROOT / "src"))
sys.path.insert(0, str(REPO_ROOT / "llm")) # atrapa serwera OpenAI
@pytest.fixture(autouse=True)
def hermetyczne_srodowisko(monkeypatch):
"""Testy nie mogą zależeć od lokalnego .env developera.
Taskfile ładuje `.env`, więc bez tego wynik testów zmieniałby się w zależności
od tego, jaki endpoint albo budżet ktoś ustawił sobie na swojej maszynie.
"""
for key in list(os.environ):
if key.startswith("CODEMOD_"):
monkeypatch.delenv(key, raising=False)
@pytest.fixture()
def repo_root() -> Path:
return REPO_ROOT
@pytest.fixture()
def acme_app(tmp_path: Path) -> Path:
"""Kopia fixture'u - testy nigdy nie modyfikują wzorca w repozytorium."""
target = tmp_path / "acme-app"
shutil.copytree(FIXTURE, target, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache"))
return target
@pytest.fixture()
def settings(tmp_path: Path):
from agentic_codemod.config import Settings
config = Settings.from_env()
config.apm_root = REPO_ROOT
config.run_dir = tmp_path / "run"
config.verify_timeout_s = 120
return config
@pytest.fixture()
def audit(tmp_path: Path):
from agentic_codemod.observability.audit import AuditLog
return AuditLog(run_dir=tmp_path / "audit")
+65
View File
@@ -0,0 +1,65 @@
"""Adaptery ekosystemów - wiedza deterministyczna, więc testowana punktowo."""
import json
import pytest
from agentic_codemod.adapters import (
EcosystemNotDetected,
MavenAdapter,
NodeNpmAdapter,
PythonPipAdapter,
detect_adapter,
)
def test_wykrywanie_ekosystemu_python(acme_app):
assert isinstance(detect_adapter(acme_app), PythonPipAdapter)
def test_brak_rozpoznanego_ekosystemu_to_twardy_blad(tmp_path):
with pytest.raises(EcosystemNotDetected):
detect_adapter(tmp_path)
def test_odczyt_i_podniesienie_wersji(acme_app):
adapter = PythonPipAdapter(acme_app)
assert adapter.read_declared_version("acme-sdk") == "==1.4.2"
changed = adapter.set_declared_version("acme-sdk", "2.1.0")
assert [p.name for p in changed] == ["pyproject.toml"]
assert adapter.read_declared_version("acme-sdk") == "==2.1.0"
def test_bump_nie_dotyka_prozy_zawierajacej_nazwe_pakietu(acme_app):
"""Regresja: wzorzec bez wymaganego operatora podmieniał też opis projektu."""
adapter = PythonPipAdapter(acme_app)
adapter.set_declared_version("acme-sdk", "2.1.0")
content = (acme_app / "pyproject.toml").read_text(encoding="utf-8")
assert "acme-sdk 1.x - fixture" in content
def test_bump_zachowuje_operator_porownania(tmp_path):
(tmp_path / "requirements.txt").write_text("acme-sdk >= 1.4\nhttpx==0.27.0\n", encoding="utf-8")
adapter = PythonPipAdapter(tmp_path)
adapter.set_declared_version("acme-sdk", "2.1.0")
assert (tmp_path / "requirements.txt").read_text(encoding="utf-8").startswith("acme-sdk >= 2.1.0")
def test_adapter_maven_podnosi_wersje_zaleznosci(tmp_path):
(tmp_path / "pom.xml").write_text(
"<project><dependencies><dependency>"
"<groupId>com.acme</groupId><artifactId>acme-sdk</artifactId><version>1.4.2</version>"
"</dependency></dependencies></project>",
encoding="utf-8",
)
adapter = MavenAdapter(tmp_path)
assert adapter.read_declared_version("com.acme:acme-sdk") == "1.4.2"
adapter.set_declared_version("com.acme:acme-sdk", "2.1.0")
assert adapter.read_declared_version("com.acme:acme-sdk") == "2.1.0"
def test_adapter_npm_zachowuje_prefiks_zakresu(tmp_path):
(tmp_path / "package.json").write_text(json.dumps({"dependencies": {"acme-sdk": "^1.4.2"}}), encoding="utf-8")
adapter = NodeNpmAdapter(tmp_path)
adapter.set_declared_version("acme-sdk", "2.1.0")
assert adapter.read_declared_version("acme-sdk") == "^2.1.0"
+102
View File
@@ -0,0 +1,102 @@
"""Kompilacja definicji APM do obiektów agno.
Test nie woła modelu - sprawdza to, co da się sprawdzić bez GPU: czy każda definicja
agenta z pakietu APM daje się zbudować, czy jej narzędzia istnieją, czy skille się walidują
i czy schemat wyjścia jest znany. To bramka na literówkę w `.agent.md`.
"""
from dataclasses import dataclass
import pytest
from agno.models.base import Model
from agentic_codemod.apm.agno_bridge import ToolResolutionError, build_agent, build_skills
from agentic_codemod.apm.loader import load_apm_context
from agentic_codemod.observability.audit import AuditLog
from agentic_codemod.tools import WorkspaceTools, build_tool_registry
from agentic_codemod.workflow.brains import SCHEMA_REGISTRY
@dataclass
class _StubModel(Model):
"""Model, który nigdy nie zostanie wywołany - potrzebny tylko po to,
by agno przyjęło konstrukcję agenta bez dostępu do endpointu.
"""
id: str = "stub"
name: str = "Stub"
provider: str = "Stub"
def invoke(self, *args, **kwargs):
raise NotImplementedError
async def ainvoke(self, *args, **kwargs):
raise NotImplementedError
def invoke_stream(self, *args, **kwargs):
raise NotImplementedError
async def ainvoke_stream(self, *args, **kwargs):
raise NotImplementedError
def _parse_provider_response(self, response, **kwargs):
raise NotImplementedError
def _parse_provider_response_delta(self, response):
raise NotImplementedError
class _StubModelFactory:
"""Zastępuje realny endpoint - budowa agenta nie może wymagać dostępnego LLM."""
def __init__(self):
self.calls = []
def for_profile(self, profile, temperature=None):
self.calls.append((profile, temperature))
return _StubModel(id=f"stub-{profile}")
@pytest.fixture()
def registry(acme_app, settings, tmp_path):
workspace = WorkspaceTools(acme_app, settings, AuditLog(run_dir=tmp_path / "audit"))
return build_tool_registry(workspace)
def test_kazda_definicja_agenta_daje_sie_zbudowac(repo_root, settings, registry):
ctx = load_apm_context(repo_root)
factory = _StubModelFactory()
registry = dict(registry)
registry["run_verification"] = lambda: "ok"
for name, definition in ctx.agents.items():
agent = build_agent(definition, ctx, settings, factory, registry, SCHEMA_REGISTRY)
assert agent.name == name
assert agent.instructions, "agent bez instrukcji to agent bez kontekstu"
assert agent.tool_call_limit and agent.tool_call_limit > 0
assert {profile for profile, _ in factory.calls} <= {"planner", "coder", "reviewer", "scribe"}
def test_schemat_wyjscia_jest_wiazany_z_rejestru(repo_root, settings, registry):
ctx = load_apm_context(repo_root)
registry = dict(registry)
registry["run_verification"] = lambda: "ok"
agent = build_agent(ctx.agent("planner"), ctx, settings, _StubModelFactory(), registry, SCHEMA_REGISTRY)
assert agent.output_schema is SCHEMA_REGISTRY["ChangePlan"]
def test_nieznane_narzedzie_w_definicji_konczy_sie_bledem(repo_root, settings, registry):
ctx = load_apm_context(repo_root)
definition = ctx.agent("coder")
definition.meta["tools"] = ["read_file", "rm_rf"]
with pytest.raises(ToolResolutionError, match="rm_rf"):
build_agent(definition, ctx, settings, _StubModelFactory(), registry, SCHEMA_REGISTRY)
def test_skille_agenta_sa_ladowane_z_katalogow_apm(repo_root):
ctx = load_apm_context(repo_root)
skills = build_skills(ctx, ctx.agent("coder").skill_names)
assert set(skills.get_skill_names()) == {"safe-code-edit", "sdk-version-upgrade"}
assert skills.get_skill("sdk-version-upgrade").references, "notatka migracyjna musi być widoczna dla agenta"
+54
View File
@@ -0,0 +1,54 @@
"""Kontekst APM jest konfiguracją sieci agentowej - musi się ładować i walidować."""
import pytest
from agentic_codemod.apm.loader import load_apm_context
from agentic_codemod.apm.primitives import parse_frontmatter
def test_kontekst_zawiera_wszystkie_role_agentow(repo_root):
ctx = load_apm_context(repo_root)
assert {"scout", "planner", "coder", "reviewer", "scribe"} <= set(ctx.agents)
assert {"repo-recon", "sdk-version-upgrade", "safe-code-edit", "merge-request-authoring"} <= set(ctx.skills)
assert {"security-guardrails", "python-conventions"} <= set(ctx.instructions)
def test_definicja_agenta_wskazuje_istniejace_skille_i_instrukcje(repo_root):
ctx = load_apm_context(repo_root)
for name, definition in ctx.agents.items():
assert ctx.skill_paths(definition.skill_names) or not definition.skill_names, name
assert ctx.instruction_bodies(definition.instruction_names) or not definition.instruction_names, name
def test_skille_sa_zgodne_ze_specyfikacja_agent_skills(repo_root):
"""agno waliduje katalogi skilli - niepoprawny skill musi wysadzić przebieg na starcie."""
from agno.skills import LocalSkills
ctx = load_apm_context(repo_root)
for ref in ctx.skills.values():
assert LocalSkills(str(ref.path), validate=True).load()
def test_prompt_renderuje_sie_z_wymaganymi_wejsciami(repo_root):
ctx = load_apm_context(repo_root)
prompt = ctx.prompt("sdk-upgrade")
rendered = prompt.render({"package": "acme-sdk", "to_version": "2.1.0", "repo": "/tmp/x"})
assert "acme-sdk" in rendered and "2.1.0" in rendered
assert "{{" not in rendered
def test_brak_wymaganego_wejscia_to_blad_konfiguracji_nie_zgadywanie(repo_root):
ctx = load_apm_context(repo_root)
with pytest.raises(ValueError, match="brak wymaganych wejść"):
ctx.prompt("sdk-upgrade").render({"package": "acme-sdk"})
def test_brakujacy_skill_konczy_sie_czytelnym_bledem(repo_root):
ctx = load_apm_context(repo_root)
with pytest.raises(KeyError, match="apm install"):
ctx.skill_paths(["nie-istnieje"])
def test_parsowanie_frontmattera_bez_naglowka():
meta, body = parse_frontmatter("zwykły tekst")
assert meta == {} and body == "zwykły tekst"
+21
View File
@@ -0,0 +1,21 @@
"""Ślad audytowy jest artefaktem CI - nie może wynosić sekretów."""
from agentic_codemod.observability.audit import redact
def test_redakcja_tokenow():
assert "glpat-" not in redact("token: glpat-ABCDEFGHIJKLMNOP")
assert "supersecret" not in redact('api_key="supersecret123"')
assert "***REDACTED***" in redact("Authorization: Bearer abcdefghijklmnop")
def test_redakcja_nie_niszczy_zwyklego_tekstu():
text = "Zmieniono plik src/acme_app/notifier.py, 9 podmian."
assert redact(text) == text
def test_log_zapisuje_wpisy_do_pliku(audit):
audit.record("read_file", {"path": "a.py"}, detail="ok")
audit.event("intake", package="acme-sdk")
assert (audit.run_dir / "trace.jsonl").read_text(encoding="utf-8").count("\n") == 2
assert audit.tool_call_count == 1
+49
View File
@@ -0,0 +1,49 @@
"""Reguły codemod z pakietu APM - deterministyczna część migracji."""
from agentic_codemod.apm.loader import load_apm_context
from agentic_codemod.workflow.codemod import apply_ruleset, load_rulesets
def _ruleset(repo_root):
ctx = load_apm_context(repo_root)
rulesets = load_rulesets([ref.path for ref in ctx.skills.values()], "acme-sdk")
assert rulesets, "pakiet APM musi dostarczać reguły dla acme-sdk"
return rulesets[0]
def test_reguly_sa_ladowane_z_referencji_skilla(repo_root):
ruleset = _ruleset(repo_root)
assert ruleset.package == "acme-sdk"
assert {rule.id for rule in ruleset.rules} >= {"import-client", "constructor", "send-message"}
def test_dry_run_nie_zmienia_plikow(repo_root, acme_app):
before = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
result = apply_ruleset(acme_app, _ruleset(repo_root), dry_run=True)
assert result.changed_files == ["src/acme_app/notifier.py"]
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == before
def test_migracja_usuwa_stare_api(repo_root, acme_app):
apply_ruleset(acme_app, _ruleset(repo_root))
content = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
assert "AcmeClient(api_key=api_key, base_url=endpoint)" in content
assert "client.messages.create(recipient=" in content
assert "client.send(" not in content
assert ".close()" not in content
assert 'result["id"]' not in content
def test_reguly_nie_dotykaja_vendorowanego_sdk(repo_root, acme_app):
before = (acme_app / "stubs/acme/__init__.py").read_text(encoding="utf-8")
apply_ruleset(acme_app, _ruleset(repo_root))
assert (acme_app / "stubs/acme/__init__.py").read_text(encoding="utf-8") == before
def test_ponowne_uruchomienie_jest_idempotentne(repo_root, acme_app):
ruleset = _ruleset(repo_root)
apply_ruleset(acme_app, ruleset)
after_first = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
second = apply_ruleset(acme_app, ruleset)
assert second.changed_files == []
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == after_first
+80
View File
@@ -0,0 +1,80 @@
"""Ścieżka agentowa (LlmBrain) przepuszczona przez atrapę serwera OpenAI.
Tryb offline sprawdza reguły; ten test sprawdza to, czego reguły nie dotykają:
budowę agentów z definicji APM, wywoływanie sandboxowanych narzędzi przez model
i parsowanie strukturalnych wyjść. Bez GPU, bez sieci, tak samo na Linuksie i macOS.
"""
from __future__ import annotations
import threading
import pytest
from mock_server import serve
from agentic_codemod.schemas import ChangeRequest, TaskType
from agentic_codemod.workflow.runner import run_pipeline
@pytest.fixture()
def mock_llm():
httpd = serve(host="127.0.0.1", port=0)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host, port = httpd.server_address[:2]
try:
yield f"http://{host}:{port}/v1"
finally:
httpd.shutdown()
thread.join(timeout=5)
@pytest.fixture()
def llm_settings(settings, mock_llm):
settings.provider = "openai_like"
settings.base_url = mock_llm
settings.api_key = "mock"
for role in ("planner", "coder", "reviewer", "scribe"):
setattr(settings.models, role, "mock/agentic-codemod")
return settings
@pytest.fixture()
def request_upgrade(acme_app):
return ChangeRequest(
task_type=TaskType.SDK_UPGRADE,
repo_path=str(acme_app),
package="acme-sdk",
module_name="acme",
from_version="1.4.2",
to_version="2.1.0",
)
def test_przebieg_z_agentami_konczy_sie_zielona_weryfikacja(request_upgrade, llm_settings, acme_app):
manifest = run_pipeline(request_upgrade, settings=llm_settings, mode="llm", in_place=True)
assert manifest.status == "success", manifest.errors
assert manifest.mode == "llm"
assert manifest.verifications and manifest.verifications[-1].passed
assert set(manifest.changed_files) == {"pyproject.toml", "src/acme_app/notifier.py"}
migrated = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
assert "AcmeClient(api_key=api_key, base_url=endpoint)" in migrated
assert "client.send(" not in migrated
def test_strukturalne_wyjscia_agentow_sa_parsowane(request_upgrade, llm_settings):
manifest = run_pipeline(request_upgrade, settings=llm_settings, mode="llm", in_place=True)
assert manifest.profile and manifest.profile.module_name == "acme"
assert manifest.plan and manifest.plan.actionable_edits[0].path == "src/acme_app/notifier.py"
assert manifest.review and manifest.review.verdict == "approve"
assert manifest.merge_request and manifest.merge_request.title.startswith("build(deps):")
def test_agent_uzywa_sandboxowanych_narzedzi(request_upgrade, llm_settings):
manifest = run_pipeline(request_upgrade, settings=llm_settings, mode="llm", in_place=True)
used = {entry["tool"] for entry in manifest.tool_calls}
assert {"read_file", "replace_in_file", "run_verification"} <= used
+91
View File
@@ -0,0 +1,91 @@
"""Przebieg end-to-end bez modelu językowego.
To jest smoke test całego pipeline'u: kontekst APM, adaptery, sandbox, workflow agno,
weryfikacja i artefakty audytowe. Uruchamialny na runnerze bez GPU i bez internetu.
"""
import json
import pytest
from agentic_codemod.schemas import ChangeRequest, TaskType
from agentic_codemod.workflow.runner import run_pipeline
@pytest.fixture()
def request_upgrade(acme_app):
return ChangeRequest(
task_type=TaskType.SDK_UPGRADE,
repo_path=str(acme_app),
package="acme-sdk",
module_name="acme",
from_version="1.4.2",
to_version="2.1.0",
)
def test_fixture_jest_czerwony_przed_migracja(acme_app):
import subprocess
proc = subprocess.run(["python3", "-m", "pytest", "-q"], cwd=acme_app, capture_output=True, text=True)
assert proc.returncode != 0, "fixture musi być czerwony, inaczej test e2e niczego nie dowodzi"
def test_przebieg_offline_konczy_sie_zielona_weryfikacja(request_upgrade, settings, acme_app):
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True)
assert manifest.status == "success"
assert manifest.verifications and manifest.verifications[-1].passed
assert set(manifest.changed_files) == {"pyproject.toml", "src/acme_app/notifier.py"}
assert manifest.review and manifest.review.verdict == "approve"
assert "acme-sdk==2.1.0" in (acme_app / "pyproject.toml").read_text(encoding="utf-8")
def test_przebieg_zapisuje_komplet_artefaktow_audytowych(request_upgrade, settings):
run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True)
run_dir = settings.run_dir
for name in (
"run.json",
"plan.json",
"profile.json",
"review.json",
"merge_request.md",
"changes.patch",
"trace.jsonl",
):
assert (run_dir / name).exists(), f"brak artefaktu {name}"
manifest = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
assert manifest["apm_context"]["skills"], "manifest musi odnotować, z jakiego kontekstu korzystał przebieg"
assert manifest["run_id"] and manifest["mode"] == "offline"
def test_tryb_plan_only_nie_zmienia_kodu(request_upgrade, settings, acme_app):
before = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True, plan_only=True)
assert manifest.status == "success"
assert manifest.plan is not None and manifest.plan.edits
assert manifest.changed_files == []
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == before
def test_praca_na_kopii_nie_rusza_katalogu_zrodlowego(request_upgrade, settings, acme_app):
before = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=False)
assert manifest.status == "success"
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == before
def test_recenzja_blokuje_gdy_weryfikacja_czerwona(request_upgrade, settings, acme_app):
"""Sabotujemy testy fixture'u: pipeline nie może zaraportować sukcesu."""
(acme_app / "tests" / "test_notifier.py").write_text(
"def test_zawsze_czerwony():\n assert False\n", encoding="utf-8"
)
settings.max_iterations = 1
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True)
assert manifest.status == "failed"
assert not manifest.verifications[-1].passed
+58
View File
@@ -0,0 +1,58 @@
"""Sandbox narzędzi plikowych to ostatnia linia obrony - testujemy go jak zabezpieczenie, nie jak feature."""
import pytest
from agentic_codemod.tools import WorkspaceTools
from agentic_codemod.tools.workspace import WorkspaceError
@pytest.fixture()
def workspace(acme_app, settings, audit):
return WorkspaceTools(acme_app, settings, audit)
def test_odczyt_pliku_zwraca_numerowane_linie(workspace):
content = workspace.read_file("src/acme_app/notifier.py")
assert " 1| " in content
def test_wyjscie_poza_repozytorium_jest_zabronione(workspace):
with pytest.raises(WorkspaceError, match="poza repozytorium"):
workspace.read_file("../../etc/passwd")
@pytest.mark.parametrize("path", [".gitlab-ci.yml", ".git/config", "deploy/secret.env", "app.pem"])
def test_sciezki_objete_zakazem_sa_blokowane(workspace, path):
with pytest.raises(WorkspaceError, match="zakaz|poza repozytorium"):
workspace.write_file(path, "cokolwiek")
def test_replace_wymaga_jednoznacznego_fragmentu(workspace):
with pytest.raises(WorkspaceError, match="występuje 2 razy"):
workspace.replace_in_file(
"src/acme_app/notifier.py",
" client = Client(api_key=api_key, endpoint=endpoint)\n",
" client = AcmeClient()\n",
)
def test_replace_bez_dopasowania_daje_wskazowke_nie_wyjatek_techniczny(workspace):
with pytest.raises(WorkspaceError, match="Odczytaj plik ponownie"):
workspace.replace_in_file("src/acme_app/notifier.py", "czegoś takiego tu nie ma", "x")
def test_udana_edycja_jest_rejestrowana(workspace):
workspace.replace_in_file("src/acme_app/notifier.py", "from acme import Client", "from acme import AcmeClient")
assert "src/acme_app/notifier.py" in workspace.changed_files
assert any(entry["tool"] == "replace_in_file" and entry["ok"] for entry in workspace.audit.entries)
def test_budzet_zmienionych_plikow_jest_egzekwowany(workspace, settings):
settings.max_files_changed = 1
workspace.write_file("a.py", "x = 1\n")
with pytest.raises(WorkspaceError, match="budżet"):
workspace.write_file("b.py", "y = 2\n")
def test_wyszukiwanie_zwraca_dopasowania(workspace):
assert "notifier.py" in workspace.search_repo("from acme import", "*.py")