"""registry_dev.py — Tool sviluppo: git, npm, pip, lint, scaffold tools leggeri. Estratto da registry.py per ridurre il file principale. Funzioni esportate: _directory_tree, _file_search, _git_status, _git_clone, _git_diff, _recall, _list_files, _diff_text, _validate_json, _lint_code_tool, _git_push, _git_sync_vfs, _git_commit, _npm_install, _npm_run, _pip_install, _type_check """ from __future__ import annotations import httpx import asyncio import subprocess import tempfile import os import sys import logging _logger = logging.getLogger("tools.registry") # ─── S763: 10 tool mancanti (S760 dichiarati mai implementati) ────────────── # Presenti in planner.py PLANNER_SYSTEM, agent.py _STEP_VISIBILITY/_NARR_QUICK, # _TOOL_NEEDED_RE ma senza _fn in TOOL_REGISTRY -> KeyError silenzioso al runtime. async def _directory_tree(path: str = ".", max_depth: int = 3, show_hidden: bool = False) -> dict: """S763: Albero filesystem con os.walk.""" import os as _os try: base = _os.path.abspath(path) if not _os.path.isdir(base): return {"error": f"Percorso non trovato: {path}"} _IGNORE = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".next", ".cache"} lines: list = [f"{base}/"] count = 0 for root, dirs, files in _os.walk(base): depth = root.replace(base, "").count(_os.sep) if depth >= max_depth: dirs.clear() continue dirs[:] = sorted(d for d in dirs if (show_hidden or not d.startswith(".")) and d not in _IGNORE) ind = " " * (depth + 1) for d in dirs: lines.append(f"{ind}+-- {d}/") for fname in sorted(files): if not show_hidden and fname.startswith("."): continue if count >= 200: lines.append(f"{ind}... (troncato)") break lines.append(f"{ind} {fname}") count += 1 return {"ok": True, "path": base, "tree": "\n".join(lines), "count": count} except Exception as e: return {"error": str(e)[:300]} async def _file_search(pattern: str, path: str = ".", file_glob: str = "*") -> dict: """S763: grep -rn con fallback Python os.walk+read.""" import os as _os try: proc = await asyncio.create_subprocess_exec( "grep", "-rn", "--include", file_glob, "--color=never", "-m", "5", pattern, _os.path.abspath(path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, _ = await asyncio.wait_for(proc.communicate(), timeout=15) lines = [l for l in out.decode("utf-8", errors="replace").splitlines() if l.strip()] if proc.returncode == 1 and not lines: return {"ok": True, "pattern": pattern, "matches": [], "count": 0, "note": "Nessun risultato"} return {"ok": True, "pattern": pattern, "path": path, "matches": lines[:50], "count": len(lines)} except (FileNotFoundError, asyncio.TimeoutError): import re as _re2 matches: list = [] try: _pat = _re2.compile(pattern, _re2.IGNORECASE) for root, _, files in _os.walk(path): for fname in files: fpath = _os.path.join(root, fname) try: with open(fpath, "r", encoding="utf-8", errors="replace") as fh: for i, line in enumerate(fh, 1): if _pat.search(line): matches.append(f"{fpath}:{i}: {line.rstrip()[:200]}") if len(matches) >= 50: break except Exception: continue if len(matches) >= 50: break except Exception as ex: return {"error": str(ex)[:300]} return {"ok": True, "pattern": pattern, "matches": matches, "count": len(matches)} except Exception as e: return {"error": str(e)[:300]} async def _git_status(cwd: str = ".") -> dict: """S763: branch + status --short + log -5. Read-only.""" try: async def _rg(*args: str) -> str: p = await asyncio.create_subprocess_exec( "git", *args, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, _ = await asyncio.wait_for(p.communicate(), timeout=10) return out.decode("utf-8", errors="replace").strip() return { "ok": True, "branch": await _rg("rev-parse", "--abbrev-ref", "HEAD"), "status": await _rg("status", "--short") or "(working tree clean)", "log": await _rg("log", "--oneline", "-5"), } except Exception as e: return {"error": str(e)[:300]} async def _git_clone(url: str, directory: str = "", depth: int = 0) -> dict: """S763: git clone [dir]. Timeout 120s. Risk medium.""" try: cmd = ["git", "clone"] if depth > 0: cmd += ["--depth", str(depth)] cmd.append(url) if directory: cmd.append(directory) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, err = await asyncio.wait_for(proc.communicate(), timeout=120) combined = (out + err).decode("utf-8", errors="replace")[:1000] if proc.returncode == 0: target = directory or url.rstrip("/").split("/")[-1].removesuffix(".git") return {"ok": True, "directory": target, "output": combined} return {"ok": False, "error": combined} except asyncio.TimeoutError: return {"ok": False, "error": "timeout 120s"} except Exception as e: return {"ok": False, "error": str(e)[:300]} async def _git_diff(cwd: str = ".", staged: bool = False) -> dict: """S763: git diff [--cached]. Stat + diff max 3000 chars. Read-only.""" try: extra = ["--cached"] if staged else [] async def _rg(*a: str) -> str: p = await asyncio.create_subprocess_exec( "git", *a, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, _ = await asyncio.wait_for(p.communicate(), timeout=15) return out.decode("utf-8", errors="replace") stat = await _rg("diff", *extra, "--stat", "--no-color") diff = await _rg("diff", *extra, "--no-color") return {"staged": staged, "stat": stat[:1000], "diff": diff[:3000] or "(nessuna modifica)"} except Exception as e: return {"error": str(e)[:300]} async def _recall(query: str, limit: int = 5) -> dict: """S-GAP13: cerca in agentMemory (chiave/valore in-process). Fallback su entries recenti.""" try: from api.state import _get_mem_manager_async as _gmm mem = await _gmm() if mem is None: return {"results": [], "note": "MemoryManager non disponibile"} results = [] try: raw = await mem.search(query, limit=limit) results = [{"key": r.get("key",""), "value": r.get("value",""), "score": r.get("score",0)} for r in (raw or [])] except Exception: try: raw = await mem.list(limit=limit * 2) q_lower = query.lower() for r in (raw or []): k = str(r.get("key","")).lower() v = str(r.get("value","")).lower() if q_lower in k or q_lower in v: results.append({"key": r.get("key",""), "value": r.get("value","")}) if len(results) >= limit: break except Exception as _exc: _logger.debug("[registry] silenced %s", type(_exc).__name__) # noqa: BLE001 return {"results": results, "count": len(results), "query": query} except Exception as e: return {"results": [], "error": str(e)[:200]} async def _list_files(path: str = ".", recursive: bool = False, max_items: int = 100) -> dict: """S-GAP13: elenca file nella directory. os.listdir/os.walk. Max 100 items.""" import os as _os try: path = _os.path.abspath(path) if not _os.path.exists(path): return {"ok": False, "error": f"Path non trovato: {path}"} if not _os.path.isdir(path): return {"ok": False, "error": f"Non e una directory: {path}"} items = [] if recursive: for root, dirs, files in _os.walk(path): dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('node_modules','__pycache__','.git')] rel_root = _os.path.relpath(root, path) for f in files: rel = _os.path.join(rel_root, f) if rel_root != '.' else f items.append(rel) if len(items) >= max_items: break if len(items) >= max_items: break else: for entry in _os.scandir(path): items.append(entry.name + ('/' if entry.is_dir() else '')) if len(items) >= max_items: break return {"ok": True, "path": path, "items": items, "count": len(items), "truncated": len(items) >= max_items} except Exception as e: return {"ok": False, "error": str(e)[:300]} def _diff_text(text_a: str, text_b: str, context_lines: int = 3) -> dict: """S-GAP13: confronta due testi con difflib.unified_diff. Restituisce patch testo.""" import difflib try: lines_a = text_a.splitlines(keepends=True) lines_b = text_b.splitlines(keepends=True) diff = list(difflib.unified_diff(lines_a, lines_b, fromfile="a", tofile="b", n=context_lines)) patch = "".join(diff) added = sum(1 for l in diff if l.startswith('+') and not l.startswith('+++')) removed = sum(1 for l in diff if l.startswith('-') and not l.startswith('---')) return {"patch": patch[:4000], "added": added, "removed": removed, "identical": len(diff) == 0} except Exception as e: return {"patch": "", "error": str(e)[:200]} def _validate_json(json_str: str, schema: dict | None = None) -> dict: """S-GAP13: valida JSON (json.loads). Con schema dict usa jsonschema se disponibile.""" import json try: parsed = json.loads(json_str) result: dict = {"valid": True, "type": type(parsed).__name__} if schema: try: import jsonschema jsonschema.validate(parsed, schema) result["schema_valid"] = True except ImportError: result["schema_note"] = "jsonschema non installato — validazione struttura skippata" except Exception as ve: result["valid"] = False result["schema_error"] = str(ve)[:400] return result except json.JSONDecodeError as e: return {"valid": False, "error": f"JSON non valido: {e.msg} (riga {e.lineno}, col {e.colno})"} except Exception as e: return {"valid": False, "error": str(e)[:200]} async def _lint_code_tool(content: str, language: str = "auto", path: str = "") -> dict: """S-GAP13: analisi statica codice. Wrapper di api.linter.lint_code. Auto-detect da estensione.""" try: if language == "auto" and path: ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" language = {"py": "python", "js": "javascript", "jsx": "javascript", "ts": "typescript", "tsx": "typescript", "json": "json"}.get(ext, "python") from api.linter import lint_code as _lc return await _lc(content=content, language=language, path=path) except Exception as e: return {"ok": False, "errors": [], "warnings": [], "error": str(e)[:200]} async def _git_push(remote: str = "origin", branch: str = "", cwd: str = ".") -> dict: """S-GAP12: git push []. Timeout 70s. Risk high.""" import asyncio as _aio try: cmd = ["git", "push", remote] if branch: cmd.append(branch) proc = await _aio.create_subprocess_exec( *cmd, cwd=cwd, stdout=_aio.subprocess.PIPE, stderr=_aio.subprocess.PIPE, ) out, err = await _aio.wait_for(proc.communicate(), timeout=65) combined = (out + err).decode("utf-8", errors="replace")[:800] return {"ok": proc.returncode == 0, "output": combined, "code": proc.returncode} except _aio.TimeoutError: return {"ok": False, "error": "git push timeout (65s)"} except Exception as e: return {"ok": False, "error": str(e)[:300]} async def _git_sync_vfs( files: dict, branch: str = "agent-state", message: str = "chore(vfs): auto-sync session", repo: str = "", ) -> dict: """ RF-1: git_sync_vfs — Commit atomico VFS→GitHub via Git Data API. Flusso: GET HEAD → POST blob×N → POST tree → POST commit → PATCH/POST ref. Branch inesistente: creato automaticamente da HEAD di main. Repo: param repo oppure env GITHUB_REPO. Fail-safe: ritorna error se GITHUB_TOKEN mancante. """ import base64 import httpx as _httpx import os as _os gh_token = _os.environ.get("GITHUB_TOKEN", "") if not gh_token: return {"success": False, "error": "GITHUB_TOKEN non configurato"} if not files: return {"success": False, "error": "Nessun file da sincronizzare"} gh_repo = repo or _os.environ.get("GITHUB_REPO", "") if not gh_repo: return {"success": False, "error": "Specifica repo='owner/repo' oppure imposta GITHUB_REPO env"} _hdrs = { "Authorization": f"token {gh_token}", "Accept": "application/vnd.github.v3+json", "Content-Type": "application/json", } base_url = f"https://api.github.com/repos/{gh_repo}" try: async with _httpx.AsyncClient(timeout=30.0, headers=_hdrs) as _c: # 1. Leggi HEAD branch target (o fallback a main) base_sha: str | None = None branch_exists = False _ref_r = await _c.get(f"{base_url}/git/ref/heads/{branch}") if _ref_r.status_code == 200: base_sha = _ref_r.json()["object"]["sha"] branch_exists = True else: _main_r = await _c.get(f"{base_url}/git/ref/heads/main") if _main_r.status_code == 200: base_sha = _main_r.json()["object"]["sha"] else: return {"success": False, "error": f"Impossibile leggere HEAD: {_main_r.status_code}"} # 2. Crea blob per ogni file tree_items: list[dict] = [] for fpath, content in files.items(): if not isinstance(content, str): content = str(content) encoded = base64.b64encode(content.encode("utf-8", errors="replace")).decode() _blob_r = await _c.post(f"{base_url}/git/blobs", json={"content": encoded, "encoding": "base64"}) if _blob_r.status_code not in (200, 201): return {"success": False, "error": f"Blob fail [{fpath}]: {_blob_r.status_code}"} tree_items.append({"path": fpath, "mode": "100644", "type": "blob", "sha": _blob_r.json()["sha"]}) # 3. Crea tree _tree_payload: dict = {"tree": tree_items} if base_sha: _tree_payload["base_tree"] = base_sha _tree_r = await _c.post(f"{base_url}/git/trees", json=_tree_payload) if _tree_r.status_code not in (200, 201): return {"success": False, "error": f"Tree fail: {_tree_r.status_code}"} tree_sha = _tree_r.json()["sha"] # 4. Crea commit _commit_payload: dict = {"message": message, "tree": tree_sha} if base_sha: _commit_payload["parents"] = [base_sha] _commit_r = await _c.post(f"{base_url}/git/commits", json=_commit_payload) if _commit_r.status_code not in (200, 201): return {"success": False, "error": f"Commit fail: {_commit_r.status_code}"} commit_sha = _commit_r.json()["sha"] # 5. PATCH ref (o POST se branch nuovo) if branch_exists: _ref_upd = await _c.patch(f"{base_url}/git/refs/heads/{branch}", json={"sha": commit_sha}) else: _ref_upd = await _c.post(f"{base_url}/git/refs", json={"ref": f"refs/heads/{branch}", "sha": commit_sha}) if _ref_upd.status_code not in (200, 201): return {"success": False, "error": f"Ref update fail: {_ref_upd.status_code} — {_ref_upd.text[:200]}"} return { "success": True, "commit_sha": commit_sha, "branch": branch, "files_synced": len(tree_items), "repo": gh_repo, "url": f"https://github.com/{gh_repo}/tree/{branch}", } except Exception as _e: return {"success": False, "error": f"git_sync_vfs errore: {str(_e)[:300]}"} async def _git_commit(message: str, cwd: str = ".", push: bool = False, add_all: bool = True) -> dict: """S763: git add -A + commit -m. push=True per git push. Risk medium.""" try: async def _run(cmd: list) -> tuple: p = await asyncio.create_subprocess_exec( *cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, err = await asyncio.wait_for(p.communicate(), timeout=30) return p.returncode, (out + err).decode("utf-8", errors="replace")[:500] if add_all: rc, out = await _run(["git", "add", "-A"]) if rc != 0: return {"ok": False, "step": "git add", "error": out} rc, out = await _run(["git", "commit", "-m", message]) if rc != 0: return {"ok": False, "step": "git commit", "error": out} result: dict = {"ok": True, "commit_output": out} if push: rc_p, out_p = await _run(["git", "push"]) result["push_ok"] = rc_p == 0 result["push_output"] = out_p return result except Exception as e: return {"ok": False, "error": str(e)[:300]} async def _npm_install(cwd: str = ".", manager: str = "auto", args: str = "") -> dict: """S763: npm/pnpm/yarn install. Auto-detecta da lockfile. Timeout 120s.""" import os as _os try: if manager == "auto": manager = ("pnpm" if _os.path.exists(_os.path.join(cwd, "pnpm-lock.yaml")) else "yarn" if _os.path.exists(_os.path.join(cwd, "yarn.lock")) else "npm") cmd = [manager, "install"] + ([args] if args else []) proc = await asyncio.create_subprocess_exec( *cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, err = await asyncio.wait_for(proc.communicate(), timeout=120) combined = (out + err).decode("utf-8", errors="replace")[:2000] return {"ok": proc.returncode == 0, "manager": manager, "output": combined, "code": proc.returncode} except asyncio.TimeoutError: return {"ok": False, "error": f"{manager} install timeout (120s)"} except Exception as e: return {"ok": False, "error": str(e)[:300]} async def _npm_run(script: str, cwd: str = ".", manager: str = "auto") -> dict: """S763: npm/pnpm/yarn run