snapcontext 3.2.0__tar.gz → 3.3.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 (44) hide show
  1. {snapcontext-3.2.0/snapcontext.egg-info → snapcontext-3.3.0}/PKG-INFO +13 -1
  2. {snapcontext-3.2.0 → snapcontext-3.3.0}/README.md +12 -0
  3. {snapcontext-3.2.0 → snapcontext-3.3.0}/agentes.py +58 -11
  4. {snapcontext-3.2.0 → snapcontext-3.3.0}/pyproject.toml +1 -1
  5. {snapcontext-3.2.0 → snapcontext-3.3.0/snapcontext.egg-info}/PKG-INFO +13 -1
  6. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.egg-info/SOURCES.txt +1 -0
  7. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.py +366 -12
  8. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_auto_017.py +1 -1
  9. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_diagnostico_310.py +1 -1
  10. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_diff_editor_210.py +3 -1
  11. snapcontext-3.3.0/tests/test_editor_330.py +347 -0
  12. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_editor_ast_220.py +1 -1
  13. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_editor_propio_200.py +1 -1
  14. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_exe_150.py +1 -1
  15. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_jetbrains_170.py +1 -1
  16. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_mcp_014.py +1 -1
  17. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_mcp_140.py +1 -1
  18. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_memoria_015.py +1 -1
  19. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_memoria_300.py +1 -1
  20. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_permisos_013.py +1 -1
  21. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_plan_012.py +1 -1
  22. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_plan_mcp_230.py +1 -1
  23. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_v010.py +1 -1
  24. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_validacion_130.py +1 -1
  25. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_vscode_016.py +1 -1
  26. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_web_160.py +1 -1
  27. {snapcontext-3.2.0 → snapcontext-3.3.0}/LICENSE +0 -0
  28. {snapcontext-3.2.0 → snapcontext-3.3.0}/orquestador.py +0 -0
  29. {snapcontext-3.2.0 → snapcontext-3.3.0}/setup.cfg +0 -0
  30. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.egg-info/__pycache__/top_level.cpython-313.pyc +0 -0
  31. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.egg-info/dependency_links.txt +0 -0
  32. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.egg-info/entry_points.txt +0 -0
  33. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.egg-info/requires.txt +0 -0
  34. {snapcontext-3.2.0 → snapcontext-3.3.0}/snapcontext.egg-info/top_level.txt +0 -0
  35. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_agentes.py +0 -0
  36. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_bienvenida_311.py +0 -0
  37. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_deteccion.py +0 -0
  38. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_embeddings_110.py +0 -0
  39. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_orquestador.py +0 -0
  40. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_vscode_ts_320.py +0 -0
  41. {snapcontext-3.2.0 → snapcontext-3.3.0}/tests/test_web_120.py +0 -0
  42. {snapcontext-3.2.0 → snapcontext-3.3.0}/web/__init__.py +0 -0
  43. {snapcontext-3.2.0 → snapcontext-3.3.0}/web/app.py +0 -0
  44. {snapcontext-3.2.0 → snapcontext-3.3.0}/web/static/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snapcontext
3
- Version: 3.2.0
3
+ Version: 3.3.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
@@ -143,6 +143,18 @@ La extensión vive en `vscode/` y está escrita en TypeScript:
143
143
  - Empaquetar: `vscode/scripts/empaquetar.ps1` / `.sh` (compilan antes de
144
144
  ejecutar `vsce package`).
145
145
 
146
+ ### Editor propio refinado (v3.3.0)
147
+ - **Detección de lenguaje robusta**: extensión + heurística por contenido
148
+ (shebang, patrones de código) para proyectos mixtos.
149
+ - **Manejo de conflictos en parches**: validación previa del contenido del
150
+ archivo y resolución automática línea a línea si `git apply`/`patch`
151
+ fallan (con aviso de aplicación parcial); ya no se sobrescribe a ciegas.
152
+ - **Prompt de edición mejorado**: lenguaje, tamaño, resumen AST con
153
+ posiciones y reglas de precisión (estilo conservado, cambios mínimos).
154
+ - **Aprendizaje de patrones**: las ediciones exitosas se guardan como skills
155
+ (`editor-<patrón>`) y su estrategia se reutiliza automáticamente en
156
+ tareas similares.
157
+
146
158
  ## 🚀 Inicio rápido (5 minutos)
147
159
 
148
160
  ```bash
@@ -98,6 +98,18 @@ La extensión vive en `vscode/` y está escrita en TypeScript:
98
98
  - Empaquetar: `vscode/scripts/empaquetar.ps1` / `.sh` (compilan antes de
99
99
  ejecutar `vsce package`).
100
100
 
101
+ ### Editor propio refinado (v3.3.0)
102
+ - **Detección de lenguaje robusta**: extensión + heurística por contenido
103
+ (shebang, patrones de código) para proyectos mixtos.
104
+ - **Manejo de conflictos en parches**: validación previa del contenido del
105
+ archivo y resolución automática línea a línea si `git apply`/`patch`
106
+ fallan (con aviso de aplicación parcial); ya no se sobrescribe a ciegas.
107
+ - **Prompt de edición mejorado**: lenguaje, tamaño, resumen AST con
108
+ posiciones y reglas de precisión (estilo conservado, cambios mínimos).
109
+ - **Aprendizaje de patrones**: las ediciones exitosas se guardan como skills
110
+ (`editor-<patrón>`) y su estrategia se reutiliza automáticamente en
111
+ tareas similares.
112
+
101
113
  ## 🚀 Inicio rápido (5 minutos)
102
114
 
103
115
  ```bash
@@ -181,15 +181,22 @@ class AgenteEditorPropio:
181
181
  self,
182
182
  parche: str,
183
183
  directorio: str = ".",
184
+ contenido_esperado: Optional[str] = None,
184
185
  ) -> bool:
185
- """Aplica un parche unificado en ``directorio``.
186
+ """Aplica un parche unificado en ``directorio`` (v3.3.0).
187
+
188
+ Con ``contenido_esperado`` se valida antes que el archivo coincida
189
+ con el usado para generar el parche y, si hay conflicto, se intenta
190
+ la resolución incremental línea a línea.
186
191
 
187
192
  Devuelve ``True`` si se aplicó con éxito, ``False`` si falló.
188
193
  """
189
194
  import snapcontext as sc
190
195
 
191
196
  sc.depurar(f"[AgenteEditorPropio] Aplicando parche en '{directorio}'")
192
- return sc._aplicar_parche(parche, directorio=directorio)
197
+ return sc._aplicar_parche_con_resolucion(
198
+ parche, directorio=directorio,
199
+ contenido_esperado=contenido_esperado)
193
200
 
194
201
  def _cadena_modos(self, archivo: str, mensaje: str,
195
202
  modo_edicion: str) -> List[str]:
@@ -233,12 +240,21 @@ class AgenteEditorPropio:
233
240
 
234
241
  pref = sc.cargar_configuracion()
235
242
  proveedor = pref.get("provider") or sc.PROVEEDOR_DEFECTO
243
+ lenguaje = sc._lenguaje_archivo(archivo, contenido_actual) or "?"
244
+ num_lineas = (contenido_actual.count("\n") + 1
245
+ if contenido_actual else 0)
236
246
  prompt = (
237
- f"Genera un parche unificado (unified diff) que modifique el archivo para cumplir con la tarea. "
238
- f"El parche debe ser aplicable con 'patch -p1' o 'git apply' (encabezados --- a/{archivo} y +++ b/{archivo}).\n\n"
247
+ f"Genera un parche unificado (unified diff) que modifique el archivo "
248
+ f"para cumplir con la tarea con precisión máxima.\n"
249
+ f"El parche debe ser aplicable con 'patch -p1' o 'git apply' "
250
+ f"(encabezados --- a/{archivo} y +++ b/{archivo}).\n\n"
239
251
  f"Tarea: {mensaje}\n"
240
- f"Archivo: {archivo}\n\n"
241
- f"Contenido actual:\n```\n{contenido_actual}\n```\n\n"
252
+ f"Archivo: {archivo} (lenguaje: {lenguaje}, {num_lineas} líneas)\n\n"
253
+ f"Instrucciones:\n"
254
+ f"- Incluye suficiente contexto (3 líneas) para anclar cada hunk.\n"
255
+ f"- Conserva el estilo existente (indentación, comillas).\n"
256
+ f"- Modifica SOLO lo necesario; no reorganices el resto del archivo.\n\n"
257
+ f"Contenido actual completo:\n```{lenguaje}\n{contenido_actual}\n```\n\n"
242
258
  f"Devuelve ÚNICAMENTE el diff unificado, sin explicaciones ni rodeos."
243
259
  )
244
260
  respuesta = sc._enviar_al_proveedor(
@@ -250,7 +266,9 @@ class AgenteEditorPropio:
250
266
  if "```" in diff_limpio:
251
267
  diff_limpio = diff_limpio[:diff_limpio.find("```")]
252
268
  if "--- " in diff_limpio and "+++ " in diff_limpio and "@@" in diff_limpio:
253
- return self.aplicar_parche(diff_limpio, directorio)
269
+ # v3.3.0: validación previa + resolución de conflictos.
270
+ return self.aplicar_parche(diff_limpio, directorio,
271
+ contenido_esperado=contenido_actual)
254
272
  return False
255
273
 
256
274
  def _aplicar_modo_sobrescribir(self, archivo: str, mensaje: str,
@@ -261,11 +279,13 @@ class AgenteEditorPropio:
261
279
 
262
280
  pref = sc.cargar_configuracion()
263
281
  proveedor = pref.get("provider") or sc.PROVEEDOR_DEFECTO
282
+ lenguaje = sc._lenguaje_archivo(archivo, contenido_actual) or "?"
264
283
  prompt = (
265
- f"Modifica el siguiente archivo para cumplir con la tarea.\n\n"
284
+ f"Modifica el siguiente archivo ({lenguaje}) para cumplir con la "
285
+ f"tarea, conservando el estilo existente y cambiando solo lo necesario.\n\n"
266
286
  f"Tarea: {mensaje}\n"
267
- f"Archivo: {archivo}\n\n"
268
- f"Contenido actual:\n```\n{contenido_actual}\n```\n\n"
287
+ f"Archivo: {archivo} (lenguaje: {lenguaje})\n\n"
288
+ f"Contenido actual completo:\n```{lenguaje}\n{contenido_actual}\n```\n\n"
269
289
  f"Devuelve ÚNICAMENTE el código completo resultante, sin explicaciones ni markdown."
270
290
  )
271
291
  nuevo_contenido = sc._enviar_al_proveedor(
@@ -298,6 +318,18 @@ class AgenteEditorPropio:
298
318
  raiz = Path(directorio).resolve()
299
319
  todo_ok = True
300
320
 
321
+ # v3.3.0 — Integración con skills: si ya existe un patrón de edición
322
+ # aprendido para esta tarea, se prioriza su estrategia (sin pasar por
323
+ # el proveedor de IA cuando el skill lo permita).
324
+ estrategia_aprendida = None
325
+ try:
326
+ estrategia_aprendida = sc._skill_editor_estrategia(mensaje)
327
+ if estrategia_aprendida:
328
+ sc.info("[skills] Reutilizando patrón de edición aprendido: "
329
+ f"'{estrategia_aprendida}'.")
330
+ except Exception as exc:
331
+ sc.depurar(f"[skills] Búsqueda de skill falló: {exc}")
332
+
301
333
  for arch in archivos:
302
334
  camino = (raiz / arch).resolve()
303
335
  contenido_actual = ""
@@ -309,6 +341,10 @@ class AgenteEditorPropio:
309
341
  pass
310
342
 
311
343
  cadena = self._cadena_modos(arch, mensaje, modo_edicion)
344
+ # El skill aprendido se coloca el primero en la cadena.
345
+ if estrategia_aprendida and estrategia_aprendida in cadena:
346
+ cadena.remove(estrategia_aprendida)
347
+ cadena.insert(0, estrategia_aprendida)
312
348
  conseguido = False
313
349
  for estrategia in cadena:
314
350
  try:
@@ -329,7 +365,18 @@ class AgenteEditorPropio:
329
365
  conseguido = False
330
366
  if conseguido:
331
367
  break
332
- if not conseguido:
368
+
369
+ if conseguido:
370
+ # v3.3.0 — Aprendizaje: guardar/reforzar el patrón de edición.
371
+ patron = sc._editor_clasificar_tarea(mensaje)
372
+ sid = sc._skill_editor_guardar(
373
+ mensaje, arch, patron, estrategia=estrategia)
374
+ if sid is not None:
375
+ try:
376
+ sc._skill_registrar_exito(sid)
377
+ except Exception as exc:
378
+ sc.depurar(f"[skills] Refuerzo falló: {exc}")
379
+ else:
333
380
  sc.error(f"[AgenteEditorPropio] No se pudo editar '{arch}'.")
334
381
  todo_ok = False
335
382
 
@@ -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 = "3.2.0"
9
+ version = "3.3.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: 3.2.0
3
+ Version: 3.3.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
@@ -143,6 +143,18 @@ La extensión vive en `vscode/` y está escrita en TypeScript:
143
143
  - Empaquetar: `vscode/scripts/empaquetar.ps1` / `.sh` (compilan antes de
144
144
  ejecutar `vsce package`).
145
145
 
146
+ ### Editor propio refinado (v3.3.0)
147
+ - **Detección de lenguaje robusta**: extensión + heurística por contenido
148
+ (shebang, patrones de código) para proyectos mixtos.
149
+ - **Manejo de conflictos en parches**: validación previa del contenido del
150
+ archivo y resolución automática línea a línea si `git apply`/`patch`
151
+ fallan (con aviso de aplicación parcial); ya no se sobrescribe a ciegas.
152
+ - **Prompt de edición mejorado**: lenguaje, tamaño, resumen AST con
153
+ posiciones y reglas de precisión (estilo conservado, cambios mínimos).
154
+ - **Aprendizaje de patrones**: las ediciones exitosas se guardan como skills
155
+ (`editor-<patrón>`) y su estrategia se reutiliza automáticamente en
156
+ tareas similares.
157
+
146
158
  ## 🚀 Inicio rápido (5 minutos)
147
159
 
148
160
  ```bash
@@ -17,6 +17,7 @@ tests/test_bienvenida_311.py
17
17
  tests/test_deteccion.py
18
18
  tests/test_diagnostico_310.py
19
19
  tests/test_diff_editor_210.py
20
+ tests/test_editor_330.py
20
21
  tests/test_editor_ast_220.py
21
22
  tests/test_editor_propio_200.py
22
23
  tests/test_embeddings_110.py
@@ -111,7 +111,7 @@ except ImportError: # pragma: no cover
111
111
  import concurrent.futures
112
112
  from concurrent.futures import ThreadPoolExecutor
113
113
 
114
- VERSION = "3.2.0"
114
+ VERSION = "3.3.0"
115
115
 
116
116
  # v3.1.0 — Claves de API reconocidas para el modo por defecto (offline).
117
117
  CLAVES_API_CONOCIDAS = (
@@ -1802,6 +1802,214 @@ def _aplicar_parche(parche: str, directorio: str = ".") -> bool:
1802
1802
  os.remove(temp_path)
1803
1803
  except OSError:
1804
1804
  pass
1805
+
1806
+
1807
+ # ---------------------------------------------------------------------------
1808
+ # Manejo de conflictos y aplicación incremental (v3.3.0)
1809
+ # ---------------------------------------------------------------------------
1810
+ def _ruta_del_parche(parche: str) -> Optional[str]:
1811
+ """Extrae la ruta del archivo objetivo del encabezado del parche.
1812
+
1813
+ Acepta encabezados ``--- a/ruta`` / ``+++ b/ruta`` y variantes sin
1814
+ prefijo. Devuelve None si no se encuentra.
1815
+ """
1816
+ for linea in (parche or "").splitlines():
1817
+ if linea.startswith("+++ "):
1818
+ ruta = linea[4:].strip().split("\t")[0]
1819
+ if ruta.startswith("b/"):
1820
+ ruta = ruta[2:]
1821
+ return ruta or None
1822
+ if linea.startswith("--- "):
1823
+ candidata = linea[4:].strip().split("\t")[0]
1824
+ if candidata.startswith("a/"):
1825
+ candidata = candidata[2:]
1826
+ if candidata and candidata not in ("/dev/null",):
1827
+ return candidata
1828
+ return None
1829
+
1830
+
1831
+ def _validar_parche_previo(parche: str, directorio: str,
1832
+ contenido_esperado: Optional[str]) -> tuple:
1833
+ """Verifica que el archivo coincide con lo usado para generar el parche.
1834
+
1835
+ Evita conflictos por cambios concurrentes: si el contenido actual del
1836
+ archivo difiere del que se pasó al proveedor, aplicar a ciegas corrompería
1837
+ la edición. Devuelve ``(ok, detalle)``.
1838
+ """
1839
+ if contenido_esperado is None:
1840
+ return True, "sin validación (no hay contenido de referencia)"
1841
+ ruta = _ruta_del_parche(parche)
1842
+ if not ruta:
1843
+ return True, "parche sin encabezado reconocible; se omite la validación"
1844
+ destino = Path(directorio or ".").resolve() / ruta
1845
+ if not destino.is_file():
1846
+ return False, f"el archivo '{ruta}' ya no existe"
1847
+ try:
1848
+ actual = destino.read_text(encoding="utf-8", errors="replace")
1849
+ except OSError as exc:
1850
+ return False, f"no se pudo leer '{ruta}': {exc}"
1851
+ if actual != contenido_esperado:
1852
+ return False, (f"'{ruta}' cambió desde que se generó el parche "
1853
+ "(posible cambio concurrente)")
1854
+ return True, "el archivo coincide con la referencia"
1855
+
1856
+
1857
+ def _parsear_hunks(parche: str) -> List[tuple]:
1858
+ """Divide un diff unificado en hunks ``(linea_inicio_original, cambios)``.
1859
+
1860
+ ``cambios`` es una lista de ``(marca, texto)`` con marca ' ', '-' o '+'.
1861
+ Se omiten los hunks sin líneas modificadas. Devuelve [] si no hay ninguno.
1862
+ """
1863
+ hunks: List[tuple] = []
1864
+ hunk_actual: Optional[List[tuple]] = None
1865
+ inicio_orig = 0
1866
+ for linea in (parche or "").splitlines(keepends=True):
1867
+ texto = linea.rstrip("\r\n")
1868
+ m = re.match(r"@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", texto)
1869
+ if m:
1870
+ if hunk_actual:
1871
+ hunks.append((inicio_orig, hunk_actual))
1872
+ inicio_orig = int(m.group(1))
1873
+ hunk_actual = []
1874
+ continue
1875
+ if hunk_actual is None:
1876
+ continue # encabezados ---/+++/ruido
1877
+ if texto.startswith("+"):
1878
+ hunk_actual.append(("+", texto[1:]))
1879
+ elif texto.startswith("-"):
1880
+ hunk_actual.append(("-", texto[1:]))
1881
+ else:
1882
+ hunk_actual.append((" ", texto[1:] if texto else ""))
1883
+ if hunk_actual:
1884
+ hunks.append((inicio_orig, hunk_actual))
1885
+ return [(i, l) for i, l in hunks if any(marca != " " for marca, _ in l)]
1886
+
1887
+
1888
+ def _aplicar_hunks_incremental(parche: str, directorio: str) -> bool:
1889
+ """Resolución automática de conflictos: aplica el parche línea a línea.
1890
+
1891
+ Estrategia puramente Python (sin git/patch): para cada hunk busca el
1892
+ bloque original con tolerancia a desfases (posiciones cercanas + búsqueda
1893
+ difusa por contexto con difflib) y aplica solo las líneas modificadas.
1894
+ Los hunks irresolubles se omiten con aviso; se escribe el resultado si al
1895
+ menos un hunk se aplicó, siempre con copia de seguridad previa.
1896
+
1897
+ Devuelve True si se aplicó algún cambio.
1898
+ """
1899
+ ruta = _ruta_del_parche(parche)
1900
+ if not ruta:
1901
+ aviso("[EditorPropio] No se pudo deducir el archivo del parche.")
1902
+ return False
1903
+ destino = Path(directorio or ".").resolve() / ruta
1904
+ if not destino.is_file():
1905
+ aviso(f"[EditorPropio] El archivo del parche no existe: {ruta}")
1906
+ return False
1907
+ try:
1908
+ original = destino.read_text(encoding="utf-8", errors="replace")
1909
+ except OSError as exc:
1910
+ error(f"[EditorPropio] No se pudo leer '{ruta}': {exc}")
1911
+ return False
1912
+
1913
+ resultado = original.splitlines()
1914
+ hunks = _parsear_hunks(parche)
1915
+ if not hunks:
1916
+ aviso("[EditorPropio] El parche no contiene hunks aplicables.")
1917
+ return False
1918
+
1919
+ def _coincide(desde: int, bloque: List[tuple]) -> bool:
1920
+ """True si ``resultado[desde:]`` encaja con las líneas no-'+'."""
1921
+ idx = desde
1922
+ for marca, texto in bloque:
1923
+ if marca == "+":
1924
+ continue
1925
+ if idx >= len(resultado) or resultado[idx] != texto:
1926
+ return False
1927
+ idx += 1
1928
+ return True
1929
+
1930
+ def _n_borrar(bloque: List[tuple]) -> int:
1931
+ return sum(1 for marca, _ in bloque if marca != "+")
1932
+
1933
+ aplicados = 0
1934
+ desplazamiento = 0 # acumulado por hunks previos
1935
+ for inicio_orig, cambios in hunks:
1936
+ base = max(inicio_orig - 1 + desplazamiento, 0)
1937
+ n_borrados = _n_borrar(cambios)
1938
+
1939
+ posicion = -1
1940
+ # 1) Coincidencia exacta cerca de la posición declarada.
1941
+ offsets = sorted({0, 1, -1, 2, -2, 3, -3, 5, -5, 10, -10, 20, -20})
1942
+ for delta in offsets:
1943
+ candidato = base + delta
1944
+ if 0 <= candidato <= len(resultado) and \
1945
+ _coincide(candidato, cambios):
1946
+ posicion = candidato
1947
+ break
1948
+ # 2) Búsqueda difusa por líneas de contexto con difflib.
1949
+ if posicion < 0:
1950
+ contexto_idx = [i for i, (m, _) in enumerate(cambios)
1951
+ if m == " " and _.strip()]
1952
+ if contexto_idx:
1953
+ primera_ctx = cambios[contexto_idx[0]][1].strip()
1954
+ for idx_linea, linea in enumerate(resultado):
1955
+ if linea.strip() == primera_ctx:
1956
+ candidato = idx_linea - contexto_idx[0]
1957
+ if _coincide(max(candidato, 0), cambios):
1958
+ posicion = max(candidato, 0)
1959
+ break
1960
+ if posicion < 0 or not _coincide(posicion, cambios):
1961
+ aviso(f"[EditorPropio] Hunk en línea {inicio_orig} no aplicable; "
1962
+ "se omite (resolución automática).")
1963
+ continue
1964
+
1965
+ nuevo_bloque = [texto for marca, texto in cambios if marca != "-"]
1966
+ resultado[posicion:posicion + n_borrados] = nuevo_bloque
1967
+ desplazamiento += len(nuevo_bloque) - n_borrados
1968
+ aplicados += 1
1969
+
1970
+ if not aplicados:
1971
+ aviso("[EditorPropio] Ningún hunk pudo aplicarse; archivo intacto.")
1972
+ return False
1973
+ if aplicados < len(hunks):
1974
+ aviso(f"[EditorPropio] Aplicación parcial: {aplicados}/{len(hunks)} "
1975
+ "hunks. Revisa el resultado antes de confirmar.")
1976
+
1977
+ exito(f"[EditorPropio] Parche aplicado con resolución incremental "
1978
+ f"(línea a línea) sobre '{ruta}'.")
1979
+ return _editor_sobrescribir(ruta, "\n".join(resultado) + "\n",
1980
+ directorio=directorio)
1981
+
1982
+
1983
+ def _aplicar_parche_con_resolucion(parche: str, directorio: str = ".",
1984
+ contenido_esperado: Optional[str] = None
1985
+ ) -> bool:
1986
+ """Aplica un parche con validación previa y resolución de conflictos.
1987
+
1988
+ Flujo (v3.3.0):
1989
+ 1. Validación previa: si se pasa ``contenido_esperado`` (el contenido
1990
+ usado para generar el parche), comprueba que el archivo actual
1991
+ coincida para evitar conflictos concurrentes.
1992
+ 2. Intento estándar: ``git apply`` → ``patch -p1``.
1993
+ 3. Resolución automática: aplicación incremental línea a línea.
1994
+ 4. Si todo falla, avisa para resolución manual (ya no sobrescribe a
1995
+ ciegas).
1996
+ """
1997
+ ok_validacion, detalle = _validar_parche_previo(
1998
+ parche, directorio, contenido_esperado)
1999
+ if not ok_validacion:
2000
+ aviso(f"[EditorPropio] Validación previa fallida: {detalle}. "
2001
+ "Se intentará la resolución automática.")
2002
+ elif contenido_esperado is not None:
2003
+ depurar(f"[EditorPropio] Validación previa OK: {detalle}")
2004
+
2005
+ if _aplicar_parche(parche, directorio=directorio):
2006
+ return True
2007
+ info("[EditorPropio] Conflicto detectado; probando resolución "
2008
+ "incremental (línea a línea)...")
2009
+ return _aplicar_hunks_incremental(parche, directorio)
2010
+
2011
+
2012
+
1805
2013
  # ---------------------------------------------------------------------------
1806
2014
  # Editor propio (Fase 3 — Edición basada en AST) — v2.2.0
1807
2015
  # ---------------------------------------------------------------------------
@@ -1873,8 +2081,8 @@ def _resumen_ast(contenido: str, ruta: str) -> dict:
1873
2081
  Devuelve un dict con ``ok``, ``motor``, ``lenguaje`` y una proyección simple
1874
2082
  (funciones/clases/imports/variables/llamadas). Nunca lanza excepciones.
1875
2083
  """
1876
- lenguaje = _lenguaje_tree_sitter(ruta) or ""
1877
- if _es_extension_python(ruta):
2084
+ lenguaje = _lenguaje_archivo(ruta, contenido) or ""
2085
+ if _es_extension_python(ruta) or lenguaje == "python":
1878
2086
  return _resumen_ast_python(contenido)
1879
2087
  if tree_sitter is not None and _ts_lang is not None and lenguaje:
1880
2088
  try:
@@ -2096,12 +2304,21 @@ def _editor_ast(archivo: str, tarea: str, directorio: str = ".",
2096
2304
  return False
2097
2305
 
2098
2306
  proveedor = proveedor or cargar_configuracion().get("provider") or PROVEEDOR_DEFECTO
2307
+ lenguaje = resumen.get("lenguaje") or _lenguaje_archivo(ruta_posix,
2308
+ contenido) or "?"
2309
+ num_lineas = contenido.count("\n") + 1
2099
2310
  prompt = (
2100
- f"Vas a modificar un archivo comprendiendo su estructura sintáctica (AST).\n\n"
2311
+ f"Vas a modificar un archivo comprendiendo su estructura sintáctica "
2312
+ f"(AST). Objetivo: precisión máxima y cambios mínimos.\n\n"
2101
2313
  f"Tarea: {tarea}\n"
2102
- f"Archivo: {ruta_posix}\n\n"
2103
- f"Resumen del AST:\n{_formatear_resumen_ast(resumen, ruta_posix)}\n\n"
2104
- f"Contenido actual:\n```\n{contenido}\n```\n\n"
2314
+ f"Archivo: {ruta_posix} (lenguaje: {lenguaje}, {num_lineas} líneas)\n\n"
2315
+ f"Resumen del AST (símbolos disponibles y sus posiciones):\n"
2316
+ f"{_formatear_resumen_ast(resumen, ruta_posix)}\n\n"
2317
+ f"Contenido actual completo:\n```\n{contenido}\n```\n\n"
2318
+ f"Reglas de edición:\n"
2319
+ f"- Conserva el estilo existente (indentación, comillas, convenciones).\n"
2320
+ f"- Modifica SOLO lo necesario para la tarea; no reorganices el resto.\n"
2321
+ f"- Usa los símbolos del resumen del AST para anclar tus cambios.\n\n"
2105
2322
  f"Responde ÚNICAMENTE con una lista JSON de operaciones de edición, por ejemplo:\n"
2106
2323
  f'[{{"tipo": "renombrar", "nombre": "viejo", "nuevo": "nuevo"}}]\n'
2107
2324
  f'O, si prefieres devolver el código completo resultante:\n'
@@ -4684,6 +4901,92 @@ def _skill_obtener(skill_id: int) -> Optional[dict]:
4684
4901
  return skill
4685
4902
 
4686
4903
 
4904
+ # ─── Skills del editor propio: patrones de edición (v3.3.0) ────────────────
4905
+ _EDITOR_PATRONES = (
4906
+ ("renombrar", re.compile(
4907
+ r"\b(renombrar|rename|cambia(r| el nombre)( de| la)? (la )?(función|"
4908
+ r"funcion|variable|clase|método|metodo))\b", re.IGNORECASE)),
4909
+ ("añadir_import", re.compile(
4910
+ r"\b(a[nñ]ad(i|ir)|importar|import|agregar)\s+(el\s+|la\s+)?"
4911
+ r"(import|módulo|modulo|librería|libreria|paquete)\b", re.IGNORECASE)),
4912
+ ("refactorizar_clase", re.compile(
4913
+ r"\b(refactoriza(r)?|reestructura|rdivide|extraer\s+clase|"
4914
+ r"reorganiza(r)?)\b.*\b(clase|class|módulo|modulo)\b", re.IGNORECASE)),
4915
+ ("añadir_funcion", re.compile(
4916
+ r"\b(a[nñ]ade?|a[nñ]adir|crear?|agrega(r)?)\s+(una?\s+)?"
4917
+ r"(función|funcion|función nueva|nueva funci|nuevo m[ée]todo|"
4918
+ r"m[ée]todo)\b", re.IGNORECASE)),
4919
+ ("corregir_error", re.compile(
4920
+ r"\b(arregla|r|corrige|fix|bug|error|fallo|excepci[oó]n)\b",
4921
+ re.IGNORECASE)),
4922
+ )
4923
+
4924
+
4925
+ def _editor_clasificar_tarea(tarea: str) -> str:
4926
+ """Clasifica una tarea de edición en un patrón conocido (v3.3.0).
4927
+
4928
+ Devuelve uno de: 'renombrar', 'añadir_import', 'refactorizar_clase',
4929
+ 'añadir_funcion', 'corregir_error' o 'general'.
4930
+ """
4931
+ texto = (tarea or "").strip()
4932
+ if not texto:
4933
+ return "general"
4934
+ for patron, regex in _EDITOR_PATRONES:
4935
+ if regex.search(texto):
4936
+ return patron
4937
+ return "general"
4938
+
4939
+
4940
+ def _skill_editor_guardar(tarea: str, archivo: str, patron: str,
4941
+ estrategia: str = "parche") -> Optional[int]:
4942
+ """Guarda/actualiza un skill con el patrón de edición exitoso (v3.3.0).
4943
+
4944
+ Idempotente por nombre (`editor-<patrón>`): si ya existe se actualiza.
4945
+ Nunca lanza excepciones (los errores de memoria solo avisan).
4946
+ """
4947
+ try:
4948
+ return _skill_guardar(
4949
+ nombre=f"editor-{patron}",
4950
+ consulta=tarea or f"editar {archivo}",
4951
+ pasos=[{
4952
+ "descripcion": (f"Edición '{patron}' aplicada con éxito "
4953
+ f"sobre {archivo}"),
4954
+ "accion": "editor_propio",
4955
+ "estrategia": estrategia,
4956
+ }],
4957
+ contexto={"archivo": archivo, "patron": patron,
4958
+ "estrategia": estrategia},
4959
+ descripcion=f"Patrón de edición del editor propio: {patron}")
4960
+ except Exception as exc: # pragma: no cover
4961
+ depurar(f"[skills-editor] No se pudo guardar el skill: {exc}")
4962
+ return None
4963
+
4964
+
4965
+ def _skill_editor_estrategia(tarea: str, umbral: float = 0.6) -> Optional[str]:
4966
+ """Busca un skill de edición previo y devuelve su estrategia (v3.3.0).
4967
+
4968
+ Permite que el editor propio aplique directamente la estrategia que ya
4969
+ funcionó para tareas similares, sin pasar por el proveedor de IA.
4970
+ Solo se aceptan skills de editor no archivados y con confiabilidad >= 0.6.
4971
+ """
4972
+ try:
4973
+ skill = _skill_buscar(f"editor {(tarea or '').strip()}", umbral=umbral)
4974
+ except Exception as exc:
4975
+ depurar(f"[skills-editor] Búsqueda falló: {exc}")
4976
+ return None
4977
+ if not skill or not str(skill.get("nombre", "")).startswith("editor-"):
4978
+ return None
4979
+ if float(skill.get("confiabilidad") or 0) < 0.6:
4980
+ return None
4981
+ for paso in skill.get("pasos") or []:
4982
+ estrategia = paso.get("estrategia")
4983
+ if estrategia in ("parche", "sobrescribir", "ast"):
4984
+ depurar(f"[skills-editor] Reutilizando estrategia "
4985
+ f"'{estrategia}' del skill #{skill.get('id')}.")
4986
+ return estrategia
4987
+ return None
4988
+
4989
+
4687
4990
  def _skill_listar(incluir_archivados: bool = False,
4688
4991
  solo_confiables: bool = False) -> List[dict]:
4689
4992
  """Lista skills ordenados por confiabilidad descendente."""
@@ -5457,18 +5760,69 @@ def _lenguaje_tree_sitter(ruta: str) -> Optional[str]:
5457
5760
  """Adivina el nombre de gramática tree-sitter para ``ruta``."""
5458
5761
  extension = Path(ruta).suffix.lower().lstrip(".")
5459
5762
  mapa = {
5460
- "py": "python", "js": "javascript", "jsx": "javascript",
5461
- "mjs": "javascript", "ts": "typescript", "tsx": "tsx",
5763
+ "py": "python", "pyi": "python", "js": "javascript", "jsx": "javascript",
5764
+ "mjs": "javascript", "cjs": "javascript", "ts": "typescript",
5765
+ "mts": "typescript", "cts": "typescript", "tsx": "tsx",
5462
5766
  "dart": "dart", "go": "go", "rs": "rust", "java": "java",
5463
5767
  "kt": "kotlin", "kts": "kotlin", "swift": "swift", "c": "c",
5464
- "h": "c", "cpp": "cpp", "cc": "cpp", "hpp": "cpp", "cs": "c_sharp",
5465
- "rb": "ruby", "php": "php", "sh": "bash", "bash": "bash",
5768
+ "h": "c", "cpp": "cpp", "cc": "cpp", "cxx": "cpp", "hpp": "cpp",
5769
+ "hh": "cpp", "hxx": "cpp", "cs": "c_sharp", "rb": "ruby",
5770
+ "php": "php", "sh": "bash", "bash": "bash", "zsh": "bash",
5466
5771
  "json": "json", "yaml": "yaml", "yml": "yaml", "toml": "toml",
5467
- "html": "html", "css": "css", "md": "markdown",
5772
+ "html": "html", "htm": "html", "css": "css", "scss": "css",
5773
+ "md": "markdown", "scala": "scala", "lua": "lua", "sql": "sql",
5774
+ "ex": "elixir", "exs": "elixir", "zig": "zig", "hs": "haskell",
5775
+ "r": "r", "vue": "vue", "svelte": "svelte",
5468
5776
  }
5469
5777
  return mapa.get(extension)
5470
5778
 
5471
5779
 
5780
+ # ─── Detección de lenguaje por contenido (v3.3.0) ──────────────────────────
5781
+ _PATRONES_LENGUAJE_CONTENIDO = (
5782
+ (re.compile(r"^#!.*\bpython\S*", re.MULTILINE), "python"),
5783
+ (re.compile(r"^#!.*\b(bash|sh|zsh)\b", re.MULTILINE), "bash"),
5784
+ (re.compile(r"^#!.*\bnode\b", re.MULTILINE), "javascript"),
5785
+ (re.compile(r"^package\s+\w+", re.MULTILINE), "go"),
5786
+ (re.compile(r"<\?php", re.IGNORECASE), "php"),
5787
+ (re.compile(r"^\s*def\s+\w+\s*\(.*\)\s*:", re.MULTILINE), "python"),
5788
+ (re.compile(r"\bfunc(?:tion)?\s+\w*\s*\(", re.MULTILINE), "javascript"),
5789
+ (re.compile(r"^\s*(public|private)?\s*class\s+\w+", re.MULTILINE), "java"),
5790
+ (re.compile(r"\bfn\s+\w+\s*\(", re.MULTILINE), "rust"),
5791
+ )
5792
+
5793
+
5794
+ def _detectar_lenguaje_contenido(contenido: str) -> Optional[str]:
5795
+ """Intenta adivinar el lenguaje a partir del contenido del archivo.
5796
+
5797
+ Se usa como refuerzo cuando la extensión es ambigua o desconocida
5798
+ (p. ej. scripts sin extensión, archivos generados, proyectos mixtos).
5799
+ """
5800
+ if not contenido:
5801
+ return None
5802
+ muestra = contenido[:4096]
5803
+ for patron, lenguaje in _PATRONES_LENGUAJE_CONTENIDO:
5804
+ if patron.search(muestra):
5805
+ return lenguaje
5806
+ return None
5807
+
5808
+
5809
+ def _lenguaje_archivo(ruta: str,
5810
+ contenido: Optional[str] = None) -> Optional[str]:
5811
+ """Detecta el lenguaje de ``ruta`` combinando extensión y contenido.
5812
+
5813
+ Prioridad: extensión conocida → heurística de contenido → None.
5814
+ Más robusto que la detección solo por extensión en proyectos mixtos.
5815
+ """
5816
+ por_extension = _lenguaje_tree_sitter(ruta)
5817
+ if por_extension:
5818
+ return por_extension
5819
+ if contenido is None:
5820
+ leido = _leer_archivo(ruta)
5821
+ contenido = leido or ""
5822
+ return _detectar_lenguaje_contenido(contenido)
5823
+
5824
+
5825
+
5472
5826
  def _extraer_simbolos_ts(arbol, lenguaje: str) -> dict:
5473
5827
  """Recorre el árbol tree-sitter y extrae funciones/clases/imports/llamadas."""
5474
5828
  funciones: List[dict] = []
@@ -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, "3.2.0")
186
+ self.assertEqual(sc.VERSION, "3.3.0")
187
187
 
188
188
 
189
189
  if __name__ == "__main__":
@@ -35,7 +35,7 @@ class TestFlagsCli310(unittest.TestCase):
35
35
  ).bienvenida)
36
36
 
37
37
  def test_version_es_3_1_0(self):
38
- self.assertEqual(sc.VERSION, "3.2.0")
38
+ self.assertEqual(sc.VERSION, "3.3.0")
39
39
 
40
40
 
41
41
  class TestApiKeyDetection(unittest.TestCase):
@@ -79,6 +79,8 @@ class TestDiffYParches(unittest.TestCase):
79
79
  codigo_completo = "def fn(): return 2\n"
80
80
 
81
81
  with mock.patch.object(sc, "_enviar_al_proveedor", side_effect=["no es un diff", codigo_completo]), \
82
+ mock.patch.object(sc, "_skill_editor_estrategia", return_value=None), \
83
+ mock.patch.object(sc, "_skill_editor_guardar", return_value=None), \
82
84
  mock.patch.object(agente, "sobrescribir", return_value=True) as sob_mock:
83
85
  ok = agente.ejecutar([archivo], "cambiar retorno a 2", directorio=str(self.raiz), modo_edicion="auto")
84
86
  self.assertTrue(ok)
@@ -96,7 +98,7 @@ class TestFlagsEdicion(unittest.TestCase):
96
98
  self.assertEqual(parser.parse_args(["--modo-edicion", "sobrescribir", "c"]).modo_edicion, "sobrescribir")
97
99
 
98
100
  def test_version_es_2_1_0(self):
99
- self.assertEqual(sc.VERSION, "3.2.0")
101
+ self.assertEqual(sc.VERSION, "3.3.0")
100
102
 
101
103
 
102
104
  if __name__ == "__main__":
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Tests del editor propio v3.3.0.
4
+
5
+ Cubre el refinamiento multi-lenguaje del AST, el manejo de conflictos en
6
+ parches (validación previa, resolución incremental) y la integración con
7
+ el sistema de skills.
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ import snapcontext as sc
21
+ from agentes import AgenteEditorPropio
22
+
23
+
24
+ class TestClasificacionTareas(unittest.TestCase):
25
+ """_editor_clasificar_tarea: patrones de edición reconocidos."""
26
+
27
+ def test_renombrar(self):
28
+ self.assertEqual(sc._editor_clasificar_tarea(
29
+ "renombrar la función calcular_total"), "renombrar")
30
+
31
+ def test_import(self):
32
+ self.assertEqual(sc._editor_clasificar_tarea(
33
+ "añadir import de typing"), "añadir_import")
34
+
35
+ def test_refactorizar(self):
36
+ self.assertEqual(sc._editor_clasificar_tarea(
37
+ "refactorizar la clase Carrito"), "refactorizar_clase")
38
+
39
+ def test_funcion_nueva(self):
40
+ self.assertEqual(sc._editor_clasificar_tarea(
41
+ "crear una función de validación"), "añadir_funcion")
42
+
43
+ def test_corregir_error(self):
44
+ self.assertEqual(sc._editor_clasificar_tarea(
45
+ "corrige el bug del checkout"), "corregir_error")
46
+
47
+ def test_general(self):
48
+ self.assertEqual(sc._editor_clasificar_tarea(
49
+ "mejorar rendimiento"), "general")
50
+ self.assertEqual(sc._editor_clasificar_tarea(""), "general")
51
+
52
+
53
+ class TestDeteccionLenguaje(unittest.TestCase):
54
+ """Detección multi-lenguaje refinada (extensión + contenido)."""
55
+
56
+ def test_extensiones_nuevas(self):
57
+ self.assertEqual(sc._lenguaje_tree_sitter("a.mts"), "typescript")
58
+ self.assertEqual(sc._lenguaje_tree_sitter("a.cxx"), "cpp")
59
+ self.assertEqual(sc._lenguaje_tree_sitter("a.zsh"), "bash")
60
+
61
+ def test_por_contenido_shebang(self):
62
+ self.assertEqual(sc._detectar_lenguaje_contenido(
63
+ "#!/usr/bin/env python3\nprint(1)\n"), "python")
64
+ self.assertEqual(sc._detectar_lenguaje_contenido(
65
+ "#!/bin/bash\necho hola\n"), "bash")
66
+
67
+ def test_lenguaje_archivo_combina(self):
68
+ self.assertEqual(sc._lenguaje_archivo("script", "#!/bin/bash\n"),
69
+ "bash")
70
+ self.assertEqual(sc._lenguaje_archivo("main.go"), "go")
71
+ self.assertIsNone(sc._lenguaje_archivo("datos.xyz", "contenido"))
72
+
73
+ def test_resumen_ast_python_sin_extension(self):
74
+ resumen = sc._resumen_ast("def hola():\n pass\n", "sin_nombre")
75
+ self.assertTrue(resumen["ok"])
76
+ self.assertEqual(resumen["motor"], "ast")
77
+
78
+
79
+ class TestRutaYValidacionParche(unittest.TestCase):
80
+ """Extracción de ruta del parche y validación previa."""
81
+
82
+ PARCH = ("--- a/modulo.py\n+++ b/modulo.py\n@@ -1 +1 @@\n-viejo\n+nuevo\n")
83
+
84
+ def setUp(self):
85
+ self.tmp = tempfile.mkdtemp(prefix="sc330_")
86
+
87
+ def tearDown(self):
88
+ shutil.rmtree(self.tmp, ignore_errors=True)
89
+
90
+ def test_ruta_del_parche(self):
91
+ self.assertEqual(sc._ruta_del_parche(self.PARCH), "modulo.py")
92
+ sin_prefijo = self.PARCH.replace("b/", "").replace("a/", "")
93
+ self.assertEqual(sc._ruta_del_parche(sin_prefijo), "modulo.py")
94
+ self.assertIsNone(sc._ruta_del_parche(""))
95
+ self.assertIsNone(sc._ruta_del_parche("sin encabezados"))
96
+
97
+ def test_validacion_sin_referencia_ok(self):
98
+ ok, _ = sc._validar_parche_previo(self.PARCH, self.tmp, None)
99
+ self.assertTrue(ok)
100
+
101
+ def test_validacion_coincide(self):
102
+ (Path(self.tmp) / "modulo.py").write_text("viejo\n", encoding="utf-8")
103
+ ok, _ = sc._validar_parche_previo(self.PARCH, self.tmp, "viejo\n")
104
+ self.assertTrue(ok)
105
+
106
+ def test_validacion_detecta_cambio_concurrente(self):
107
+ (Path(self.tmp) / "modulo.py").write_text("otra cosa\n",
108
+ encoding="utf-8")
109
+ ok, detalle = sc._validar_parche_previo(self.PARCH, self.tmp,
110
+ "viejo\n")
111
+ self.assertFalse(ok)
112
+ self.assertIn("concurrente", detalle)
113
+
114
+ def test_validacion_archivo_inexistente(self):
115
+ ok, detalle = sc._validar_parche_previo(self.PARCH, self.tmp,
116
+ "viejo\n")
117
+ self.assertFalse(ok)
118
+ self.assertIn("no existe", detalle)
119
+
120
+
121
+ class TestParsearHunks(unittest.TestCase):
122
+ def test_hunks_basicos(self):
123
+ parche = ("--- a/f.py\n+++ b/f.py\n"
124
+ "@@ -1,3 +1,3 @@\n ctx\n-old\n+new\n ctx2\n")
125
+ hunks = sc._parsear_hunks(parche)
126
+ self.assertEqual(len(hunks), 1)
127
+ inicio, cambios = hunks[0]
128
+ self.assertEqual(inicio, 1)
129
+ self.assertEqual(cambios, [(" ", "ctx"), ("-", "old"),
130
+ ("+", "new"), (" ", "ctx2")])
131
+
132
+ def test_sin_hunks(self):
133
+ self.assertEqual(sc._parsear_hunks(""), [])
134
+ self.assertEqual(sc._parsear_hunks("--- a/f.py\n+++ b/f.py\n"), [])
135
+
136
+
137
+ class TestAplicacionIncremental(unittest.TestCase):
138
+ """Resolución automática línea a línea sobre archivos reales."""
139
+
140
+ def setUp(self):
141
+ self.tmp = tempfile.mkdtemp(prefix="sc330inc_")
142
+
143
+ def tearDown(self):
144
+ shutil.rmtree(self.tmp, ignore_errors=True)
145
+
146
+ def _escribir(self, nombre: str, contenido: str) -> str:
147
+ (Path(self.tmp) / nombre).write_text(contenido, encoding="utf-8")
148
+ return nombre
149
+
150
+ def test_aplica_parche_limpio_linea_a_linea(self):
151
+ original = "linea 1\nlinea 2\nlinea 3\nlinea 4\nlinea 5\n"
152
+ nuevo = "linea 1\nlinea DOS\nlinea 3\nlinea CUATRO\nlinea 5\n"
153
+ nombre = self._escribir("f.py", original)
154
+ parche = sc._generar_parche(original, nuevo, nombre)
155
+ self.assertTrue(sc._aplicar_hunks_incremental(parche, self.tmp))
156
+ resultado = (Path(self.tmp) / nombre).read_text(encoding="utf-8")
157
+ self.assertEqual(resultado, nuevo)
158
+
159
+ def test_aplica_con_desfase_de_lineas(self):
160
+ # El archivo tiene 2 líneas extra al principio: git apply fallaría,
161
+ # pero la resolución incremental encuentra el bloque por desfase.
162
+ original = "linea 1\nlinea 2\nlinea 3\nlinea 4\nlinea 5\n"
163
+ nuevo = "linea 1\nlinea 2\nlinea 3\nlinea CUATRO\nlinea 5\n"
164
+ nombre = self._escribir("g.py",
165
+ "extra A\nextra B\n" + original)
166
+ parche = sc._generar_parche(original, nuevo, nombre)
167
+ self.assertTrue(sc._aplicar_hunks_incremental(parche, self.tmp))
168
+ resultado = (Path(self.tmp) / nombre).read_text(encoding="utf-8")
169
+ self.assertEqual(
170
+ resultado,
171
+ "extra A\nextra B\nlinea 1\nlinea 2\nlinea 3\n"
172
+ "linea CUATRO\nlinea 5\n")
173
+
174
+ def test_falla_si_nada_coincide(self):
175
+ self._escribir("h.py", "totalmente distinto\notra cosa\n")
176
+ parche = ("--- a/h.py\n+++ b/h.py\n@@ -1 +1 @@\n-viejo\n+nuevo\n")
177
+ self.assertFalse(sc._aplicar_hunks_incremental(parche, self.tmp))
178
+
179
+
180
+ class TestResolucionConConflictos(unittest.TestCase):
181
+ """_aplicar_parche_con_resolucion: flujo completo de resolución."""
182
+
183
+ def setUp(self):
184
+ self.tmp = tempfile.mkdtemp(prefix="sc330res_")
185
+ self.parche = ("--- a/m.py\n+++ b/m.py\n@@ -1 +1 @@\n-viejo\n+nuevo\n")
186
+ (Path(self.tmp) / "m.py").write_text("viejo\n", encoding="utf-8")
187
+
188
+ def tearDown(self):
189
+ shutil.rmtree(self.tmp, ignore_errors=True)
190
+
191
+ def test_git_apply_exitoso_no_requiere_resolucion(self):
192
+ with mock.patch.object(sc, "_aplicar_parche", return_value=True), \
193
+ mock.patch.object(sc, "_aplicar_hunks_incremental") as inc:
194
+ self.assertTrue(sc._aplicar_parche_con_resolucion(
195
+ self.parche, self.tmp))
196
+ inc.assert_not_called()
197
+
198
+ def test_conflicto_cae_a_resolucion_incremental(self):
199
+ with mock.patch.object(sc, "_aplicar_parche", return_value=False), \
200
+ mock.patch.object(sc, "_aplicar_hunks_incremental",
201
+ return_value=True) as inc:
202
+ self.assertTrue(sc._aplicar_parche_con_resolucion(
203
+ self.parche, self.tmp))
204
+ inc.assert_called_once_with(self.parche, self.tmp)
205
+
206
+ def test_validacion_previa_avisa_pero_intenta(self):
207
+ # Contenido esperado distinto del actual → aviso y reintento igual.
208
+ with mock.patch.object(sc, "_aplicar_parche", return_value=True), \
209
+ mock.patch.object(sc, "aviso") as avisos:
210
+ ok = sc._aplicar_parche_con_resolucion(
211
+ self.parche, self.tmp, contenido_esperado="otro\n")
212
+ self.assertTrue(ok)
213
+ self.assertTrue(any("Validación previa fallida" in str(c.args[0])
214
+ for c in avisos.call_args_list))
215
+
216
+
217
+ class TestSkillsEditor(unittest.TestCase):
218
+ """Integración del editor propio con el sistema de skills."""
219
+
220
+ def test_guardar_skill_editor_usa_nombre_idempotente(self):
221
+ with mock.patch.object(sc, "_skill_guardar",
222
+ return_value=42) as guardar:
223
+ sid = sc._skill_editor_guardar("renombrar la función x",
224
+ "modulo.py", "renombrar",
225
+ estrategia="ast")
226
+ self.assertEqual(sid, 42)
227
+ kwargs = guardar.call_args.kwargs
228
+ self.assertEqual(kwargs["nombre"], "editor-renombrar")
229
+ self.assertEqual(kwargs["pasos"][0]["estrategia"], "ast")
230
+ self.assertEqual(kwargs["contexto"]["archivo"], "modulo.py")
231
+
232
+ def test_guardar_skill_nunca_lanza(self):
233
+ with mock.patch.object(sc, "_skill_guardar",
234
+ side_effect=RuntimeError("db rota")):
235
+ self.assertIsNone(sc._skill_editor_guardar("t", "a.py", "general"))
236
+
237
+ def test_estrategia_desde_skill_valido(self):
238
+ skill = {"id": 7, "nombre": "editor-renombrar",
239
+ "confiabilidad": 0.8,
240
+ "pasos": [{"accion": "editor_propio",
241
+ "estrategia": "parche"}]}
242
+ with mock.patch.object(sc, "_skill_buscar", return_value=skill):
243
+ self.assertEqual(
244
+ sc._skill_editor_estrategia("renombrar la función y"),
245
+ "parche")
246
+
247
+ def test_ignora_skills_que_no_son_del_editor(self):
248
+ skill = {"id": 1, "nombre": "tarea-cualquiera", "confiabilidad": 1.0,
249
+ "pasos": [{"estrategia": "parche"}]}
250
+ with mock.patch.object(sc, "_skill_buscar", return_value=skill):
251
+ self.assertIsNone(sc._skill_editor_estrategia("lo que sea"))
252
+
253
+ def test_ignora_baja_confiabilidad(self):
254
+ skill = {"id": 2, "nombre": "editor-general",
255
+ "confiabilidad": 0.3,
256
+ "pasos": [{"estrategia": "sobrescribir"}]}
257
+ with mock.patch.object(sc, "_skill_buscar", return_value=skill):
258
+ self.assertIsNone(sc._skill_editor_estrategia("lo que sea"))
259
+
260
+
261
+ class TestIntegracionSkillsEnEjecutar(unittest.TestCase):
262
+ """AgenteEditorPropio.ejecutar reutiliza skills y guarda el patrón."""
263
+
264
+ def setUp(self):
265
+ self.tmp = tempfile.mkdtemp(prefix="sc330skills_")
266
+
267
+ def tearDown(self):
268
+ shutil.rmtree(self.tmp, ignore_errors=True)
269
+
270
+ def test_exito_guarda_patron_y_refuerza(self):
271
+ agente = AgenteEditorPropio()
272
+ archivo = "modulo.py"
273
+ (Path(self.tmp) / archivo).write_text("def fn(): return 1\n",
274
+ encoding="utf-8")
275
+ diff = ("--- a/modulo.py\n+++ b/modulo.py\n@@ -1 +1 @@\n"
276
+ "-def fn(): return 1\n+def fn(): return 2\n")
277
+ with mock.patch.object(sc, "_enviar_al_proveedor",
278
+ return_value=diff), \
279
+ mock.patch.object(agente, "aplicar_parche", return_value=True), \
280
+ mock.patch.object(sc, "_skill_editor_estrategia",
281
+ return_value=None) as buscar, \
282
+ mock.patch.object(sc, "_skill_editor_guardar",
283
+ return_value=9) as guardar, \
284
+ mock.patch.object(sc, "_skill_registrar_exito",
285
+ return_value=0.65) as reforzar:
286
+ ok = agente.ejecutar([archivo], "corrige el retorno",
287
+ directorio=self.tmp, modo_edicion="parche")
288
+ self.assertTrue(ok)
289
+ buscar.assert_called_once()
290
+ guardar.assert_called_once()
291
+ args, _kwargs = guardar.call_args
292
+ self.assertEqual(args[2], "corregir_error") # patrón clasificado
293
+ reforzar.assert_called_once_with(9)
294
+
295
+ def test_skill_aprendido_prioriza_estrategia(self):
296
+ agente = AgenteEditorPropio()
297
+ archivo = "modulo.py"
298
+ (Path(self.tmp) / archivo).write_text("x\n", encoding="utf-8")
299
+ with mock.patch.object(agente, "_cadena_modos",
300
+ return_value=["parche", "sobrescribir"]), \
301
+ mock.patch.object(sc, "_skill_editor_estrategia",
302
+ return_value="sobrescribir"), \
303
+ mock.patch.object(sc, "_skill_editor_guardar",
304
+ return_value=None), \
305
+ mock.patch.object(agente, "_aplicar_modo_sobrescribir",
306
+ return_value=True) as sob, \
307
+ mock.patch.object(agente, "_aplicar_modo_parche") as parche:
308
+ ok = agente.ejecutar([archivo], "cambia algo",
309
+ directorio=self.tmp, modo_edicion="auto")
310
+ self.assertTrue(ok)
311
+ # La estrategia aprendida ('sobrescribir') se ejecutó primero.
312
+ sob.assert_called_once()
313
+ parche.assert_not_called()
314
+
315
+
316
+ class TestPromptEditacionEnriquecido(unittest.TestCase):
317
+ """El prompt de parche incluye lenguaje, tamaño e instrucciones."""
318
+
319
+ def setUp(self):
320
+ self.tmp = tempfile.mkdtemp(prefix="sc330prompt_")
321
+
322
+ def tearDown(self):
323
+ shutil.rmtree(self.tmp, ignore_errors=True)
324
+
325
+ def test_prompt_parche_contiene_lenguaje_e_instrucciones(self):
326
+ agente = AgenteEditorPropio()
327
+ diff = ("--- a/modulo.py\n+++ b/modulo.py\n@@ -1 +1 @@\n"
328
+ "-def fn(): return 1\n+def fn(): return 2\n")
329
+ with mock.patch.object(sc, "_enviar_al_proveedor",
330
+ return_value=diff) as enviar, \
331
+ mock.patch.object(agente, "aplicar_parche", return_value=True):
332
+ agente._aplicar_modo_parche(
333
+ "modulo.py", "cambiar retorno a 2",
334
+ contenido_actual="def fn(): return 1\n",
335
+ modelo=None, directorio=self.tmp)
336
+ prompt = enviar.call_args[0][2][0]["content"]
337
+ self.assertIn("lenguaje: python", prompt)
338
+ self.assertIn("Instrucciones:", prompt)
339
+ self.assertIn("Conserva el estilo existente", prompt)
340
+ self.assertIn("```python", prompt)
341
+
342
+
343
+ if __name__ == "__main__":
344
+ unittest.main()
345
+
346
+
347
+
@@ -174,7 +174,7 @@ class TestFlagsEdicionAST(unittest.TestCase):
174
174
  self.assertEqual(args2.modo_edicion, "auto")
175
175
 
176
176
  def test_version_es_2_2_0(self):
177
- self.assertEqual(sc.VERSION, "3.2.0")
177
+ self.assertEqual(sc.VERSION, "3.3.0")
178
178
 
179
179
 
180
180
  if __name__ == "__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, "3.2.0")
87
+ self.assertEqual(sc.VERSION, "3.3.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, "3.2.0")
17
+ self.assertEqual(sc.VERSION, "3.3.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, "3.2.0")
20
+ self.assertEqual(sc.VERSION, "3.3.0")
21
21
 
22
22
 
23
23
  class TestEstructuraJetBrains(unittest.TestCase):
@@ -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, "3.2.0")
220
+ self.assertEqual(sc.VERSION, "3.3.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, "3.2.0")
251
+ self.assertEqual(sc.VERSION, "3.3.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, "3.2.0")
182
+ self.assertEqual(sc.VERSION, "3.3.0")
183
183
 
184
184
 
185
185
  if __name__ == "__main__":
@@ -312,7 +312,7 @@ class TestFlagsCLI(unittest.TestCase):
312
312
  self.assertEqual(args.daemon_intervalo, 24)
313
313
 
314
314
  def test_version_300(self):
315
- self.assertEqual(sc.VERSION, "3.2.0")
315
+ self.assertEqual(sc.VERSION, "3.3.0")
316
316
 
317
317
 
318
318
  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, "3.2.0")
185
+ self.assertEqual(sc.VERSION, "3.3.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, "3.2.0")
272
+ self.assertEqual(sc.VERSION, "3.3.0")
273
273
 
274
274
 
275
275
  if __name__ == "__main__":
@@ -210,7 +210,7 @@ class TestEjecutarToolExecuteCommand(unittest.TestCase):
210
210
 
211
211
  class TestVersionYFlags(unittest.TestCase):
212
212
  def test_version_230(self):
213
- self.assertEqual(sc.VERSION, "3.2.0")
213
+ self.assertEqual(sc.VERSION, "3.3.0")
214
214
 
215
215
  def test_flag_paralelo(self):
216
216
  args = sc.crear_parser().parse_args(["--plan", "t", "--paralelo", "3"])
@@ -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, "3.2.0")
171
+ self.assertEqual(sc.VERSION, "3.3.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, "3.2.0")
129
+ self.assertEqual(sc.VERSION, "3.3.0")
130
130
 
131
131
 
132
132
  if __name__ == "__main__":
@@ -91,7 +91,7 @@ class TestEstructuraVsCode(unittest.TestCase):
91
91
 
92
92
  class TestVersionCli(unittest.TestCase):
93
93
  def test_version_es_1_2_0(self):
94
- self.assertEqual(sc.VERSION, "3.2.0")
94
+ self.assertEqual(sc.VERSION, "3.3.0")
95
95
 
96
96
 
97
97
  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, "3.2.0")
20
+ self.assertEqual(sc.VERSION, "3.3.0")
21
21
 
22
22
 
23
23
  class TestFrontendV160(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes
File without changes