snapcontext 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {snapcontext-2.0.0/snapcontext.egg-info → snapcontext-2.1.0}/PKG-INFO +12 -4
  2. {snapcontext-2.0.0 → snapcontext-2.1.0}/README.md +11 -3
  3. {snapcontext-2.0.0 → snapcontext-2.1.0}/agentes.py +114 -2
  4. {snapcontext-2.0.0 → snapcontext-2.1.0}/orquestador.py +8 -32
  5. {snapcontext-2.0.0 → snapcontext-2.1.0}/pyproject.toml +1 -1
  6. {snapcontext-2.0.0 → snapcontext-2.1.0/snapcontext.egg-info}/PKG-INFO +12 -4
  7. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.egg-info/SOURCES.txt +1 -0
  8. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.py +90 -37
  9. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_auto_017.py +1 -1
  10. snapcontext-2.1.0/tests/test_diff_editor_210.py +103 -0
  11. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_editor_propio_200.py +1 -1
  12. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_exe_150.py +1 -1
  13. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_jetbrains_170.py +2 -2
  14. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_mcp_014.py +1 -1
  15. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_mcp_140.py +1 -1
  16. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_memoria_015.py +1 -1
  17. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_permisos_013.py +1 -1
  18. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_plan_012.py +1 -1
  19. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_v010.py +1 -1
  20. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_validacion_130.py +1 -1
  21. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_vscode_016.py +2 -2
  22. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_web_160.py +1 -1
  23. {snapcontext-2.0.0 → snapcontext-2.1.0}/web/app.py +1 -1
  24. {snapcontext-2.0.0 → snapcontext-2.1.0}/LICENSE +0 -0
  25. {snapcontext-2.0.0 → snapcontext-2.1.0}/setup.cfg +0 -0
  26. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.egg-info/__pycache__/top_level.cpython-313.pyc +0 -0
  27. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.egg-info/dependency_links.txt +0 -0
  28. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.egg-info/entry_points.txt +0 -0
  29. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.egg-info/requires.txt +0 -0
  30. {snapcontext-2.0.0 → snapcontext-2.1.0}/snapcontext.egg-info/top_level.txt +0 -0
  31. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_agentes.py +0 -0
  32. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_deteccion.py +0 -0
  33. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_embeddings_110.py +0 -0
  34. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_orquestador.py +0 -0
  35. {snapcontext-2.0.0 → snapcontext-2.1.0}/tests/test_web_120.py +0 -0
  36. {snapcontext-2.0.0 → snapcontext-2.1.0}/web/__init__.py +0 -0
  37. {snapcontext-2.0.0 → snapcontext-2.1.0}/web/static/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snapcontext
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: SnapContext: Asistente de IA con contexto automático para desarrollo
5
5
  Author-email: SnapContext Contributors <tu@email.com>
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
 
46
46
  # SnapContext
47
47
 
48
- ![v2.0.0](https://img.shields.io/badge/version-2.0.0-blue.svg)
48
+ ![v2.1.0](https://img.shields.io/badge/version-2.1.0-blue.svg)
49
49
  [![PyPI](https://badge.fury.io/py/snapcontext.svg)](https://pypi.org/project/snapcontext/)
50
50
  [![CI](https://img.shields.io/github/actions/workflow/status/NicolasBruna24/snapcontext/ci.yml?branch=main&label=tests)](https://github.com/NicolasBruña24/snapcontext/actions)
51
51
  ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
@@ -104,14 +104,22 @@ snapcontext interactive # o snapcontext --web
104
104
  Auto-detección de proyecto (Flutter, Node/React, Python, Go, Rust…) que ajusta
105
105
  carpetas, extensiones y comandos de test por defecto.
106
106
 
107
- ## 🛠️ Editor Integrado Propio (v2.0.0)
107
+ ## 🛠️ Editor Integrado Propio (v2.1.0)
108
108
 
109
- SnapContext v2.0.0 incluye su propio editor integrado:
109
+ SnapContext incluye su propio editor integrado con soporte de parches unificados:
110
110
  - **`--editor propio`**: Aplica cambios directamente y crea copias de seguridad automáticas en `~/.snapcontext/backups/`.
111
+ - **`--modo-edicion {auto,parche,sobrescribir}`**:
112
+ - `auto` (por defecto): Genera un parche unificado (`git apply` / `patch`) y hace fallback a sobrescritura si no es aplicable.
113
+ - `parche`: Fuerza la aplicación de parches unificados para ediciones precisas.
114
+ - `sobrescribir`: Sobrescribe el archivo completo.
111
115
  - **`--editor aider`** (por defecto): Mantiene el flujo de edición con Aider para máxima compatibilidad.
112
116
 
113
117
  ```bash
118
+ # Edición precisa mediante diffs unificados
114
119
  snapcontext "añadir endpoint de métricas" --editor propio
120
+
121
+ # Forzar modo parche
122
+ snapcontext "corregir tipado en login" --editor propio --modo-edicion parche
115
123
  ```
116
124
 
117
125
  ## 🧭 Modos y alias
@@ -1,6 +1,6 @@
1
1
  # SnapContext
2
2
 
3
- ![v2.0.0](https://img.shields.io/badge/version-2.0.0-blue.svg)
3
+ ![v2.1.0](https://img.shields.io/badge/version-2.1.0-blue.svg)
4
4
  [![PyPI](https://badge.fury.io/py/snapcontext.svg)](https://pypi.org/project/snapcontext/)
5
5
  [![CI](https://img.shields.io/github/actions/workflow/status/NicolasBruna24/snapcontext/ci.yml?branch=main&label=tests)](https://github.com/NicolasBruña24/snapcontext/actions)
6
6
  ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
@@ -59,14 +59,22 @@ snapcontext interactive # o snapcontext --web
59
59
  Auto-detección de proyecto (Flutter, Node/React, Python, Go, Rust…) que ajusta
60
60
  carpetas, extensiones y comandos de test por defecto.
61
61
 
62
- ## 🛠️ Editor Integrado Propio (v2.0.0)
62
+ ## 🛠️ Editor Integrado Propio (v2.1.0)
63
63
 
64
- SnapContext v2.0.0 incluye su propio editor integrado:
64
+ SnapContext incluye su propio editor integrado con soporte de parches unificados:
65
65
  - **`--editor propio`**: Aplica cambios directamente y crea copias de seguridad automáticas en `~/.snapcontext/backups/`.
66
+ - **`--modo-edicion {auto,parche,sobrescribir}`**:
67
+ - `auto` (por defecto): Genera un parche unificado (`git apply` / `patch`) y hace fallback a sobrescritura si no es aplicable.
68
+ - `parche`: Fuerza la aplicación de parches unificados para ediciones precisas.
69
+ - `sobrescribir`: Sobrescribe el archivo completo.
66
70
  - **`--editor aider`** (por defecto): Mantiene el flujo de edición con Aider para máxima compatibilidad.
67
71
 
68
72
  ```bash
73
+ # Edición precisa mediante diffs unificados
69
74
  snapcontext "añadir endpoint de métricas" --editor propio
75
+
76
+ # Forzar modo parche
77
+ snapcontext "corregir tipado en login" --editor propio --modo-edicion parche
70
78
  ```
71
79
 
72
80
  ## 🧭 Modos y alias
@@ -138,8 +138,10 @@ class AgenteEditorPropio:
138
138
  """Agente Editor Propio: editor integrado de SnapContext.
139
139
 
140
140
  Aplica cambios directamente sobre el sistema de archivos sin depender de
141
- herramientas externas. En Fase 1 realiza sobrescritura segura con copia de
142
- seguridad previa automática en ``~/.snapcontext/backups/``.
141
+ herramientas externas obligatorias.
142
+ - Fase 1: Sobrescritura segura con copia de seguridad previa en ``~/.snapcontext/backups/``.
143
+ - Fase 2: Generación y aplicación de parches unificados (unified diffs) con ``git apply`` / ``patch``
144
+ y fallback automático a sobrescritura.
143
145
  """
144
146
 
145
147
  def sobrescribir(
@@ -159,6 +161,116 @@ class AgenteEditorPropio:
159
161
  )
160
162
  return sc._editor_sobrescribir(archivo, contenido, directorio=directorio)
161
163
 
164
+ def aplicar_parche(
165
+ self,
166
+ parche: str,
167
+ directorio: str = ".",
168
+ ) -> bool:
169
+ """Aplica un parche unificado en ``directorio``.
170
+
171
+ Devuelve ``True`` si se aplicó con éxito, ``False`` si falló.
172
+ """
173
+ import snapcontext as sc
174
+
175
+ sc.depurar(f"[AgenteEditorPropio] Aplicando parche en '{directorio}'")
176
+ return sc._aplicar_parche(parche, directorio=directorio)
177
+
178
+ def ejecutar(
179
+ self,
180
+ archivos: List[str],
181
+ mensaje: str,
182
+ directorio: str = ".",
183
+ modo_edicion: str = "auto",
184
+ modelo: Optional[str] = None,
185
+ ) -> bool:
186
+ """Aplica las modificaciones para `archivos` con el `mensaje` especificado.
187
+
188
+ - modo_edicion == 'parche': intenta aplicar parche unificado (falla si no puede).
189
+ - modo_edicion == 'sobrescribir': aplica directamente sobrescritura de archivo.
190
+ - modo_edicion == 'auto': intenta primero parche unificado; si falla o no es diff,
191
+ hace fallback a sobrescritura.
192
+ """
193
+ import snapcontext as sc
194
+ from pathlib import Path
195
+
196
+ pref = sc.cargar_configuracion()
197
+ proveedor = pref.get("provider") or sc.PROVEEDOR_DEFECTO
198
+ raiz = Path(directorio).resolve()
199
+ todo_ok = True
200
+
201
+ for arch in archivos:
202
+ camino = (raiz / arch).resolve()
203
+ contenido_actual = ""
204
+ if camino.is_file():
205
+ try:
206
+ contenido_actual = camino.read_text(encoding="utf-8", errors="replace")
207
+ except Exception:
208
+ pass
209
+
210
+ if modo_edicion in ("parche", "auto"):
211
+ prompt = (
212
+ f"Genera un parche unificado (unified diff) que modifique el archivo para cumplir con la tarea. "
213
+ f"El parche debe ser aplicable con 'patch -p1' o 'git apply' (encabezados --- a/{arch} y +++ b/{arch}).\n\n"
214
+ f"Tarea: {mensaje}\n"
215
+ f"Archivo: {arch}\n\n"
216
+ f"Contenido actual:\n```\n{contenido_actual}\n```\n\n"
217
+ f"Devuelve ÚNICAMENTE el diff unificado, sin explicaciones ni rodeos."
218
+ )
219
+ try:
220
+ respuesta = sc._enviar_al_proveedor(
221
+ proveedor, modelo,
222
+ [{"role": "user", "content": prompt}]
223
+ )
224
+ # Limpiar delimitadores markdown si vinieron
225
+ diff_limpio = respuesta
226
+ if "--- " in diff_limpio and "+++ " in diff_limpio:
227
+ # Extraer solo desde ---
228
+ idx_inicio = diff_limpio.find("--- ")
229
+ diff_limpio = diff_limpio[idx_inicio:]
230
+ if "```" in diff_limpio:
231
+ diff_limpio = diff_limpio[:diff_limpio.find("```")]
232
+
233
+ # Si parece un diff unificado válido, intentar aplicarlo
234
+ if ("--- " in diff_limpio and "+++ " in diff_limpio and "@@" in diff_limpio):
235
+ if self.aplicar_parche(diff_limpio, str(raiz)):
236
+ continue
237
+ sc.aviso(f"[AgenteEditorPropio] Parche para '{arch}' falló al aplicarse.")
238
+ if modo_edicion == "parche":
239
+ todo_ok = False
240
+ continue
241
+ except Exception as exc:
242
+ sc.depurar(f"[AgenteEditorPropio] Error pidiendo parche para '{arch}': {exc}")
243
+ if modo_edicion == "parche":
244
+ sc.error(f"Error generando parche para {arch}: {exc}")
245
+ todo_ok = False
246
+ continue
247
+
248
+ # Fallback o modo sobrescribir
249
+ sc.depurar(f"[AgenteEditorPropio] Aplicando edición por sobrescritura para '{arch}'...")
250
+ prompt_sobrescribir = (
251
+ f"Modifica el siguiente archivo para cumplir con la tarea.\n\n"
252
+ f"Tarea: {mensaje}\n"
253
+ f"Archivo: {arch}\n\n"
254
+ f"Contenido actual:\n```\n{contenido_actual}\n```\n\n"
255
+ f"Devuelve ÚNICAMENTE el código completo resultante, sin explicaciones ni markdown."
256
+ )
257
+ try:
258
+ nuevo_contenido = sc._enviar_al_proveedor(
259
+ proveedor, modelo,
260
+ [{"role": "user", "content": prompt_sobrescribir}]
261
+ )
262
+ if nuevo_contenido.startswith("```"):
263
+ lineas = nuevo_contenido.splitlines()
264
+ if len(lineas) >= 2 and lineas[-1].startswith("```"):
265
+ nuevo_contenido = "\n".join(lineas[1:-1])
266
+ if not self.sobrescribir(arch, nuevo_contenido, str(raiz)):
267
+ todo_ok = False
268
+ except Exception as exc:
269
+ sc.error(f"Error generando cambios por sobrescritura para {arch}: {exc}")
270
+ todo_ok = False
271
+
272
+ return todo_ok
273
+
162
274
 
163
275
 
164
276
 
@@ -180,38 +180,14 @@ class Orquestador:
180
180
  if editor_tipo == "propio":
181
181
  sc.depurar("[Orquestador] Modo edición directa (AgenteEditorPropio).")
182
182
  self._emitir_tipo("editor", accion="iniciar", archivos=seleccion)
183
- pref = sc.cargar_configuracion()
184
- proveedor = pref.get("provider") or sc.PROVEEDOR_DEFECTO
185
- ok = True
186
- for arch in seleccion:
187
- camino = (raiz / arch).resolve()
188
- contenido_actual = ""
189
- if camino.is_file():
190
- try:
191
- contenido_actual = camino.read_text(encoding="utf-8", errors="replace")
192
- except Exception:
193
- pass
194
- prompt_editor = (
195
- f"Modifica el siguiente archivo para cumplir con la tarea.\n\n"
196
- f"Tarea: {consulta}\n"
197
- f"Archivo: {arch}\n\n"
198
- f"Contenido actual:\n```\n{contenido_actual}\n```\n\n"
199
- f"Devuelve ÚNICAMENTE el código completo resultante, sin explicaciones ni markdown."
200
- )
201
- try:
202
- nuevo_contenido = sc._enviar_al_proveedor(
203
- proveedor, getattr(args, "modelo", None),
204
- [{"role": "user", "content": prompt_editor}]
205
- )
206
- if nuevo_contenido.startswith("```"):
207
- lineas = nuevo_contenido.splitlines()
208
- if len(lineas) >= 2 and lineas[-1].startswith("```"):
209
- nuevo_contenido = "\n".join(lineas[1:-1])
210
- if not self.agente_editor_propio.sobrescribir(arch, nuevo_contenido, str(raiz)):
211
- ok = False
212
- except Exception as exc:
213
- sc.error(f"Error generando cambios para {arch}: {exc}")
214
- ok = False
183
+ modo_ed = getattr(args, "modo_edicion", "auto") or "auto"
184
+ ok = self.agente_editor_propio.ejecutar(
185
+ seleccion,
186
+ consulta,
187
+ directorio=str(raiz),
188
+ modo_edicion=modo_ed,
189
+ modelo=getattr(args, "modelo", None),
190
+ )
215
191
  self._emitir_tipo("editor", accion="fin", ok=ok)
216
192
  else:
217
193
  sc.depurar("[Orquestador] Modo edición directa (AgenteEditor).")
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # Nombre verificado disponible en PyPI (18/08/2026): pypi.org/pypi/snapcontext → 404.
7
7
  # Alternativas si algún día estuviera ocupado: "snapcontext-cli" o "snapcontext-tool".
8
8
  name = "snapcontext"
9
- version = "2.0.0"
9
+ version = "2.1.0"
10
10
  description = "SnapContext: Asistente de IA con contexto automático para desarrollo"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snapcontext
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: SnapContext: Asistente de IA con contexto automático para desarrollo
5
5
  Author-email: SnapContext Contributors <tu@email.com>
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
 
46
46
  # SnapContext
47
47
 
48
- ![v2.0.0](https://img.shields.io/badge/version-2.0.0-blue.svg)
48
+ ![v2.1.0](https://img.shields.io/badge/version-2.1.0-blue.svg)
49
49
  [![PyPI](https://badge.fury.io/py/snapcontext.svg)](https://pypi.org/project/snapcontext/)
50
50
  [![CI](https://img.shields.io/github/actions/workflow/status/NicolasBruna24/snapcontext/ci.yml?branch=main&label=tests)](https://github.com/NicolasBruña24/snapcontext/actions)
51
51
  ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
@@ -104,14 +104,22 @@ snapcontext interactive # o snapcontext --web
104
104
  Auto-detección de proyecto (Flutter, Node/React, Python, Go, Rust…) que ajusta
105
105
  carpetas, extensiones y comandos de test por defecto.
106
106
 
107
- ## 🛠️ Editor Integrado Propio (v2.0.0)
107
+ ## 🛠️ Editor Integrado Propio (v2.1.0)
108
108
 
109
- SnapContext v2.0.0 incluye su propio editor integrado:
109
+ SnapContext incluye su propio editor integrado con soporte de parches unificados:
110
110
  - **`--editor propio`**: Aplica cambios directamente y crea copias de seguridad automáticas en `~/.snapcontext/backups/`.
111
+ - **`--modo-edicion {auto,parche,sobrescribir}`**:
112
+ - `auto` (por defecto): Genera un parche unificado (`git apply` / `patch`) y hace fallback a sobrescritura si no es aplicable.
113
+ - `parche`: Fuerza la aplicación de parches unificados para ediciones precisas.
114
+ - `sobrescribir`: Sobrescribe el archivo completo.
111
115
  - **`--editor aider`** (por defecto): Mantiene el flujo de edición con Aider para máxima compatibilidad.
112
116
 
113
117
  ```bash
118
+ # Edición precisa mediante diffs unificados
114
119
  snapcontext "añadir endpoint de métricas" --editor propio
120
+
121
+ # Forzar modo parche
122
+ snapcontext "corregir tipado en login" --editor propio --modo-edicion parche
115
123
  ```
116
124
 
117
125
  ## 🧭 Modos y alias
@@ -14,6 +14,7 @@ snapcontext.egg-info/__pycache__/top_level.cpython-313.pyc
14
14
  tests/test_agentes.py
15
15
  tests/test_auto_017.py
16
16
  tests/test_deteccion.py
17
+ tests/test_diff_editor_210.py
17
18
  tests/test_editor_propio_200.py
18
19
  tests/test_embeddings_110.py
19
20
  tests/test_exe_150.py
@@ -39,6 +39,7 @@ Open-source y pensado para ser fácil de extender (ver ejecutar_bucle_test).
39
39
 
40
40
  import argparse
41
41
  import ast
42
+ import difflib
42
43
  import fnmatch
43
44
  import json
44
45
  import os
@@ -110,7 +111,7 @@ except ImportError: # pragma: no cover
110
111
  import concurrent.futures
111
112
  from concurrent.futures import ThreadPoolExecutor
112
113
 
113
- VERSION = "2.0.0"
114
+ VERSION = "2.1.0"
114
115
 
115
116
 
116
117
  # ─── Configuración por tipo de proyecto ────────────────────────────────────
@@ -1532,6 +1533,78 @@ def _editor_sobrescribir(archivo: str, contenido: str,
1532
1533
  return False
1533
1534
 
1534
1535
 
1536
+ def _generar_parche(original: str, nuevo: str, ruta_archivo: str) -> str:
1537
+ """Genera un parche unificado (unified diff) entre `original` y `nuevo`.
1538
+
1539
+ El encabezado cumple con el estándar de `patch` y `git apply` (a/ruta b/ruta).
1540
+ """
1541
+ ruta_posix = str(ruta_archivo).replace("\\", "/").strip()
1542
+ if ruta_posix.startswith("./"):
1543
+ ruta_posix = ruta_posix[2:]
1544
+
1545
+ lineas_orig = original.splitlines(keepends=True)
1546
+ lineas_nuevo = nuevo.splitlines(keepends=True)
1547
+
1548
+ diff = difflib.unified_diff(
1549
+ lineas_orig,
1550
+ lineas_nuevo,
1551
+ fromfile=f"a/{ruta_posix}",
1552
+ tofile=f"b/{ruta_posix}",
1553
+ )
1554
+ return "".join(diff)
1555
+
1556
+
1557
+ def _aplicar_parche(parche: str, directorio: str = ".") -> bool:
1558
+ """Aplica un parche unificado en `directorio` usando `git apply` o `patch`.
1559
+
1560
+ 1. Escribe el parche en un archivo temporal.
1561
+ 2. Intenta aplicar con `git apply --whitespace=nowarn <temp_file>`.
1562
+ 3. Si `git` falla o no está disponible, intenta con `patch -p1 -i <temp_file>`.
1563
+ 4. Devuelve True si se aplicó limpiamente, False si hubo error o conflicto.
1564
+ """
1565
+ if not parche or not parche.strip():
1566
+ aviso("[EditorPropio] Parche vacío; no se aplicaron cambios.")
1567
+ return False
1568
+
1569
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".diff", encoding="utf-8", delete=False) as f:
1570
+ f.write(parche)
1571
+ temp_path = f.name
1572
+
1573
+ try:
1574
+ flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
1575
+ raiz_res = str(Path(directorio).resolve())
1576
+
1577
+ # Intentar primero con git apply (muy estándar en repos de desarrollo)
1578
+ if shutil.which("git"):
1579
+ cmd_git = ["git", "apply", "--whitespace=nowarn", temp_path]
1580
+ res_git = subprocess.run(
1581
+ cmd_git, cwd=raiz_res, capture_output=True, text=True, creationflags=flags
1582
+ )
1583
+ if res_git.returncode == 0:
1584
+ exito("[EditorPropio] Parche unificado aplicado correctamente con git apply.")
1585
+ return True
1586
+ depurar(f"[EditorPropio] git apply falló (código {res_git.returncode}): {res_git.stderr}")
1587
+
1588
+ # Fallback a patch
1589
+ if shutil.which("patch"):
1590
+ cmd_patch = ["patch", "-p1", "-i", temp_path]
1591
+ res_patch = subprocess.run(
1592
+ cmd_patch, cwd=raiz_res, capture_output=True, text=True, creationflags=flags
1593
+ )
1594
+ if res_patch.returncode == 0:
1595
+ exito("[EditorPropio] Parche unificado aplicado correctamente con patch.")
1596
+ return True
1597
+ depurar(f"[EditorPropio] patch falló (código {res_patch.returncode}): {res_patch.stderr}")
1598
+
1599
+ aviso("[EditorPropio] No se pudo aplicar el parche automáticamente (ni git apply ni patch tuvieron éxito).")
1600
+ return False
1601
+ finally:
1602
+ try:
1603
+ os.remove(temp_path)
1604
+ except OSError:
1605
+ pass
1606
+
1607
+
1535
1608
  def _extraer_error(resultado: "subprocess.CompletedProcess") -> str:
1536
1609
  """Une stdout+stderr, limpia códigos ANSI y limita el tamaño del error
1537
1610
  que se mostrará a Aider (evita llenar el contexto)."""
@@ -3139,40 +3212,14 @@ def _ejecutar_paso_plan(paso: dict, args: argparse.Namespace,
3139
3212
  _, ruta_raiz, _, seleccion = plan
3140
3213
 
3141
3214
  if editor_elegido == "propio":
3142
- # Con editor propio sin contenido predefinido, pedimos al proveedor que genere el contenido
3143
- preferencias = cargar_configuracion()
3144
- proveedor = preferencias.get("provider") or PROVEEDOR_DEFECTO
3145
- todo_ok = True
3146
- for arch in seleccion:
3147
- camino = (Path(ruta_raiz) / arch).resolve()
3148
- contenido_actual = ""
3149
- if camino.is_file():
3150
- try:
3151
- contenido_actual = camino.read_text(encoding="utf-8", errors="replace")
3152
- except Exception:
3153
- pass
3154
- prompt_editor = (
3155
- f"Modifica el siguiente archivo para cumplir con la tarea.\n\n"
3156
- f"Tarea: {descripcion}\n"
3157
- f"Archivo: {arch}\n\n"
3158
- f"Contenido actual:\n```\n{contenido_actual}\n```\n\n"
3159
- f"Devuelve ÚNICAMENTE el código completo resultante, sin explicaciones ni markdown."
3160
- )
3161
- try:
3162
- nuevo_contenido = _enviar_al_proveedor(
3163
- proveedor, getattr(args, "modelo", None),
3164
- [{"role": "user", "content": prompt_editor}]
3165
- )
3166
- # Limpiar bloques de código si vinieron envueltos en ```
3167
- if nuevo_contenido.startswith("```"):
3168
- lineas = nuevo_contenido.splitlines()
3169
- if len(lineas) >= 2 and lineas[-1].startswith("```"):
3170
- nuevo_contenido = "\n".join(lineas[1:-1])
3171
- if not _editor_sobrescribir(arch, nuevo_contenido, str(ruta_raiz)):
3172
- todo_ok = False
3173
- except Exception as exc:
3174
- error(f"Error generando cambios para {arch}: {exc}")
3175
- todo_ok = False
3215
+ modo_ed = getattr(args, "modo_edicion", "auto") or "auto"
3216
+ todo_ok = orch.agente_editor_propio.ejecutar(
3217
+ seleccion,
3218
+ descripcion,
3219
+ directorio=str(ruta_raiz),
3220
+ modo_edicion=modo_ed,
3221
+ modelo=getattr(args, "modelo", None),
3222
+ )
3176
3223
  return (todo_ok, f"EditorPropio sobre {len(seleccion)} archivo(s)")
3177
3224
 
3178
3225
  if getattr(args, "test_loop", False):
@@ -4988,7 +5035,7 @@ def _pintar(texto: str, clave: str) -> str:
4988
5035
  # Categorías en orden de aparición; cada opción se muestra una sola vez.
4989
5036
  CATEGORIAS_AYUDA = (
4990
5037
  ("Modos de ejecución",
4991
- ("--plan", "--auto", "--editor", "--chat", "--web", "--web-puerto", "--demo",
5038
+ ("--plan", "--auto", "--editor", "--modo-edicion", "--chat", "--web", "--web-puerto", "--demo",
4992
5039
  "--init", "--init-claude", "--historial", "--historial-limpiar")),
4993
5040
  ("Selección de archivos",
4994
5041
  ("consulta", "--local", "--iniciar-proyecto", "--experto",
@@ -5427,7 +5474,13 @@ def crear_parser() -> argparse.ArgumentParser:
5427
5474
  "--editor", choices=["aider", "propio"], default="aider",
5428
5475
  help="Editor a usar para aplicar cambios: 'aider' (por defecto, "
5429
5476
  "requiere Aider instalado) o 'propio' (editor integrado de "
5430
- "SnapContext, sobrescribe archivos directamente con backup automático).",
5477
+ "SnapContext con soporte de parches unificados y backups automáticos).",
5478
+ )
5479
+ parser.add_argument(
5480
+ "--modo-edicion", choices=["sobrescribir", "parche", "auto"], default="auto",
5481
+ help="Estrategia del editor propio: 'auto' (intenta aplicar parche unificado, "
5482
+ "fallback a sobrescritura), 'parche' (solo parches unificados) o "
5483
+ "'sobrescribir' (sobrescritura completa del archivo).",
5431
5484
  )
5432
5485
  # Ayuda agrupada por categorías (-h/--help) — v1.7.
5433
5486
  parser.add_argument(
@@ -183,7 +183,7 @@ class TestFlagsAutoCli(unittest.TestCase):
183
183
  self.assertTrue(args.plan)
184
184
 
185
185
  def test_version_es_1_2_0(self):
186
- self.assertEqual(sc.VERSION, "2.0.0")
186
+ self.assertEqual(sc.VERSION, "2.1.0")
187
187
 
188
188
 
189
189
  if __name__ == "__main__":
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Tests de la Fase 2 del Editor Propio (Diffs y Parches Unificados) — v2.1.0."""
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ import snapcontext as sc
13
+ from agentes import AgenteEditorPropio
14
+
15
+
16
+ class TestDiffYParches(unittest.TestCase):
17
+ def setUp(self):
18
+ self.tmp_dir = tempfile.mkdtemp()
19
+ self.raiz = Path(self.tmp_dir).resolve()
20
+ self.backups_dir = self.raiz / ".snapcontext_backups"
21
+ self.patch_backup = mock.patch.object(sc, "BACKUPS_DIR", self.backups_dir)
22
+ self.patch_backup.start()
23
+
24
+ def tearDown(self):
25
+ self.patch_backup.stop()
26
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
27
+
28
+ def test_generar_parche_unificado(self):
29
+ orig = "linea 1\nlinea 2\nlinea 3\n"
30
+ nuevo = "linea 1\nlinea 2 modificada\nlinea 3\n"
31
+ parche = sc._generar_parche(orig, nuevo, "src/archivo.py")
32
+ self.assertIn("--- a/src/archivo.py", parche)
33
+ self.assertIn("+++ b/src/archivo.py", parche)
34
+ self.assertIn("-linea 2", parche)
35
+ self.assertIn("+linea 2 modificada", parche)
36
+
37
+ def test_aplicar_parche_vacio_devuelve_false(self):
38
+ self.assertFalse(sc._aplicar_parche("", str(self.raiz)))
39
+ self.assertFalse(sc._aplicar_parche(" ", str(self.raiz)))
40
+
41
+ def test_aplicar_parche_con_git_mock(self):
42
+ parche = "--- a/test.py\n+++ b/test.py\n@@ -1 +1 @@\n-hola\n+mundo\n"
43
+ proc_mock = mock.MagicMock(returncode=0, stderr="")
44
+ with mock.patch("shutil.which", return_value="git"), \
45
+ mock.patch("subprocess.run", return_value=proc_mock) as sub_mock:
46
+ ok = sc._aplicar_parche(parche, str(self.raiz))
47
+ self.assertTrue(ok)
48
+ sub_mock.assert_called_once()
49
+ cmd_args = sub_mock.call_args[0][0]
50
+ self.assertEqual(cmd_args[:2], ["git", "apply"])
51
+
52
+ def test_aplicar_parche_fallback_patch_mock(self):
53
+ parche = "--- a/test.py\n+++ b/test.py\n@@ -1 +1 @@\n-hola\n+mundo\n"
54
+ proc_fail = mock.MagicMock(returncode=1, stderr="error git")
55
+ proc_ok = mock.MagicMock(returncode=0, stderr="")
56
+ with mock.patch("shutil.which", side_effect=lambda x: x if x in ("git", "patch") else None), \
57
+ mock.patch("subprocess.run", side_effect=[proc_fail, proc_ok]) as sub_mock:
58
+ ok = sc._aplicar_parche(parche, str(self.raiz))
59
+ self.assertTrue(ok)
60
+ self.assertEqual(sub_mock.call_count, 2)
61
+
62
+ def test_ejecutar_modo_parche_exitoso(self):
63
+ agente = AgenteEditorPropio()
64
+ archivo = "modulo.py"
65
+ (self.raiz / archivo).write_text("def fn(): return 1\n", encoding="utf-8")
66
+ diff_simulado = "--- a/modulo.py\n+++ b/modulo.py\n@@ -1 +1 @@\n-def fn(): return 1\n+def fn(): return 2\n"
67
+
68
+ with mock.patch.object(sc, "_enviar_al_proveedor", return_value=diff_simulado), \
69
+ mock.patch.object(agente, "aplicar_parche", return_value=True) as ap_mock:
70
+ ok = agente.ejecutar([archivo], "cambiar retorno a 2", directorio=str(self.raiz), modo_edicion="parche")
71
+ self.assertTrue(ok)
72
+ ap_mock.assert_called_once()
73
+
74
+ def test_ejecutar_fallback_a_sobrescritura(self):
75
+ agente = AgenteEditorPropio()
76
+ archivo = "modulo.py"
77
+ (self.raiz / archivo).write_text("def fn(): return 1\n", encoding="utf-8")
78
+ # Proveedor devuelve código completo en lugar de diff
79
+ codigo_completo = "def fn(): return 2\n"
80
+
81
+ with mock.patch.object(sc, "_enviar_al_proveedor", side_effect=["no es un diff", codigo_completo]), \
82
+ mock.patch.object(agente, "sobrescribir", return_value=True) as sob_mock:
83
+ ok = agente.ejecutar([archivo], "cambiar retorno a 2", directorio=str(self.raiz), modo_edicion="auto")
84
+ self.assertTrue(ok)
85
+ sob_mock.assert_called_once_with(archivo, codigo_completo, str(self.raiz))
86
+
87
+
88
+ class TestFlagsEdicion(unittest.TestCase):
89
+ def test_flag_modo_edicion_por_defecto_auto(self):
90
+ args = sc.crear_parser().parse_args(["consulta"])
91
+ self.assertEqual(args.modo_edicion, "auto")
92
+
93
+ def test_flag_modo_edicion_parche_y_sobrescribir(self):
94
+ parser = sc.crear_parser()
95
+ self.assertEqual(parser.parse_args(["--modo-edicion", "parche", "c"]).modo_edicion, "parche")
96
+ self.assertEqual(parser.parse_args(["--modo-edicion", "sobrescribir", "c"]).modo_edicion, "sobrescribir")
97
+
98
+ def test_version_es_2_1_0(self):
99
+ self.assertEqual(sc.VERSION, "2.1.0")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ unittest.main()
@@ -84,7 +84,7 @@ class TestFlagsEditor(unittest.TestCase):
84
84
  self.assertIsInstance(orch.agente_editor_propio, AgenteEditorPropio)
85
85
 
86
86
  def test_version_es_2_0_0(self):
87
- self.assertEqual(sc.VERSION, "2.0.0")
87
+ self.assertEqual(sc.VERSION, "2.1.0")
88
88
 
89
89
 
90
90
  if __name__ == "__main__":
@@ -14,7 +14,7 @@ import snapcontext as sc # noqa: E402
14
14
 
15
15
  class TestVersion(unittest.TestCase):
16
16
  def test_version_150_coherente(self):
17
- self.assertEqual(sc.VERSION, "2.0.0")
17
+ self.assertEqual(sc.VERSION, "2.1.0")
18
18
 
19
19
 
20
20
  class TestEstaticoWeb(unittest.TestCase):
@@ -17,7 +17,7 @@ import snapcontext as sc # noqa: E402
17
17
 
18
18
  class TestVersion(unittest.TestCase):
19
19
  def test_version_170_coherente(self):
20
- self.assertEqual(sc.VERSION, "2.0.0")
20
+ self.assertEqual(sc.VERSION, "2.1.0")
21
21
 
22
22
 
23
23
  class TestEstructuraJetBrains(unittest.TestCase):
@@ -90,7 +90,7 @@ class TestGradleYKotlin(unittest.TestCase):
90
90
  def test_gradle_configura_intellij(self):
91
91
  gradle = (JB / "build.gradle.kts").read_text(encoding="utf-8")
92
92
  self.assertIn("org.jetbrains.intellij", gradle)
93
- self.assertIn('version = "2.0.0"', gradle)
93
+ self.assertIn('version = "2.1.0"', gradle)
94
94
  self.assertIn("patchPluginXml", gradle)
95
95
 
96
96
  def test_kotlin_usa_processbuilder(self):
@@ -217,7 +217,7 @@ class TestFormatoYAutoContexto(BaseMCP):
217
217
 
218
218
  class TestVersionMCPCli(BaseMCP):
219
219
  def test_version_es_1_2_0(self):
220
- self.assertEqual(sc.VERSION, "2.0.0")
220
+ self.assertEqual(sc.VERSION, "2.1.0")
221
221
 
222
222
 
223
223
  if __name__ == "__main__":
@@ -248,7 +248,7 @@ class TestChatComandosGrafo(unittest.TestCase):
248
248
 
249
249
  class TestVersion(unittest.TestCase):
250
250
  def test_version_140_coherente(self):
251
- self.assertEqual(sc.VERSION, "2.0.0")
251
+ self.assertEqual(sc.VERSION, "2.1.0")
252
252
 
253
253
 
254
254
  if __name__ == "__main__":
@@ -179,7 +179,7 @@ class TestFlagsMemoriaCli(BaseMemoria):
179
179
  ["--init-claude"]).init_claude)
180
180
 
181
181
  def test_version_es_1_2_0(self):
182
- self.assertEqual(sc.VERSION, "2.0.0")
182
+ self.assertEqual(sc.VERSION, "2.1.0")
183
183
 
184
184
 
185
185
  if __name__ == "__main__":
@@ -182,7 +182,7 @@ class TestFlagsConfirmarCLI(unittest.TestCase):
182
182
  self.assertFalse(self._parse(["--chat", "--no-confirmar"]).confirmar)
183
183
 
184
184
  def test_version_es_1_2_0(self):
185
- self.assertEqual(sc.VERSION, "2.0.0")
185
+ self.assertEqual(sc.VERSION, "2.1.0")
186
186
 
187
187
 
188
188
  if __name__ == "__main__":
@@ -269,7 +269,7 @@ class TestFlagsPlanCLI(unittest.TestCase):
269
269
  self.assertIsNone(self._parse(["--plan", "x"]).branch)
270
270
 
271
271
  def test_version_es_1_2_0(self):
272
- self.assertEqual(sc.VERSION, "2.0.0")
272
+ self.assertEqual(sc.VERSION, "2.1.0")
273
273
 
274
274
 
275
275
  if __name__ == "__main__":
@@ -168,7 +168,7 @@ class TestFlagsCLI(unittest.TestCase):
168
168
  self.assertTrue(args.vista_previa)
169
169
 
170
170
  def test_version_es_1_2_0(self):
171
- self.assertEqual(sc.VERSION, "2.0.0")
171
+ self.assertEqual(sc.VERSION, "2.1.0")
172
172
 
173
173
 
174
174
  class TestComandosAgenteChat(unittest.TestCase):
@@ -126,7 +126,7 @@ class TestIniciarProyecto(unittest.TestCase):
126
126
  class TestVersion(unittest.TestCase):
127
127
  def test_version_130_coherente(self):
128
128
  import snapcontext as sc
129
- self.assertEqual(sc.VERSION, "2.0.0")
129
+ self.assertEqual(sc.VERSION, "2.1.0")
130
130
 
131
131
 
132
132
  if __name__ == "__main__":
@@ -30,7 +30,7 @@ class TestEstructuraVsCode(unittest.TestCase):
30
30
  def test_package_json_valido(self):
31
31
  manifiesto = json.loads((VSCODE / "package.json").read_text("utf-8"))
32
32
  self.assertEqual(manifiesto["name"], "snapcontext-vscode")
33
- self.assertEqual(manifiesto["version"], "2.0.0")
33
+ self.assertEqual(manifiesto["version"], "2.1.0")
34
34
  self.assertEqual(manifiesto["main"], "./extension.js")
35
35
 
36
36
  def test_comandos_contribuidos(self):
@@ -87,7 +87,7 @@ class TestEstructuraVsCode(unittest.TestCase):
87
87
 
88
88
  class TestVersionCli(unittest.TestCase):
89
89
  def test_version_es_1_2_0(self):
90
- self.assertEqual(sc.VERSION, "2.0.0")
90
+ self.assertEqual(sc.VERSION, "2.1.0")
91
91
 
92
92
 
93
93
  if __name__ == "__main__":
@@ -17,7 +17,7 @@ INDEX = RAIZ / "web" / "static" / "index.html"
17
17
 
18
18
  class TestVersion(unittest.TestCase):
19
19
  def test_version_160_coherente(self):
20
- self.assertEqual(sc.VERSION, "2.0.0")
20
+ self.assertEqual(sc.VERSION, "2.1.0")
21
21
 
22
22
 
23
23
  class TestFrontendV160(unittest.TestCase):
@@ -44,7 +44,7 @@ _DIRECTORIO_DEFECTO = "."
44
44
 
45
45
  def crear_app() -> FastAPI:
46
46
  """Construye y devuelve la app FastAPI (rutas + WebSocket)."""
47
- app = FastAPI(title="SnapContext Web", version="2.0.0")
47
+ app = FastAPI(title="SnapContext Web", version="2.1.0")
48
48
 
49
49
  @app.get("/")
50
50
  async def raiz():
File without changes
File without changes
File without changes