webscope-mcp 0.2.0__py3-none-any.whl
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.
- webscope.py +695 -0
- webscope_adaptadores/__init__.py +1 -0
- webscope_adaptadores/costo_sesion.py +187 -0
- webscope_adaptadores/e2e_chrome.py +259 -0
- webscope_adaptadores/laravel.py +215 -0
- webscope_adaptadores/limpiar_zz.py +176 -0
- webscope_adaptadores/payload_check.py +272 -0
- webscope_adaptadores/proyecto.py +62 -0
- webscope_adaptadores/uso_agentes.py +68 -0
- webscope_adaptadores/uso_webscope.py +85 -0
- webscope_adaptadores/vue_ts.py +207 -0
- webscope_adaptadores/web/chequeo-ui.js +45 -0
- webscope_adaptadores/web/e2e-rutas.js +124 -0
- webscope_adaptadores/web/form.js +333 -0
- webscope_mcp-0.2.0.dist-info/METADATA +249 -0
- webscope_mcp-0.2.0.dist-info/RECORD +20 -0
- webscope_mcp-0.2.0.dist-info/WHEEL +5 -0
- webscope_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- webscope_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- webscope_mcp-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configuración del proyecto para los adaptadores: dónde están los módulos del backend y cómo se consulta o escribe en la base.
|
|
2
|
+
|
|
3
|
+
Se lee la sección `proyecto` del manifiesto (variable WEBSCOPE_MANIFIESTO) y la raíz de WEBSCOPE_RAIZ (o el directorio actual);
|
|
4
|
+
`webscope.py` define ambas al correr un script. Todas las rutas son relativas a la raíz.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
RAIZ = Path(os.environ.get("WEBSCOPE_RAIZ") or Path.cwd())
|
|
14
|
+
DEFECTO = {
|
|
15
|
+
"modulos": "app/Modules",
|
|
16
|
+
"carpetas": {"requests": "Requests", "servicios": "Services", "recursos": "Resources"},
|
|
17
|
+
"sql": "",
|
|
18
|
+
"tinker": "",
|
|
19
|
+
"php_db": "DB::connection()",
|
|
20
|
+
"prefijo_rutas": "",
|
|
21
|
+
"contenedor": "",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config():
|
|
26
|
+
"""Sección `proyecto` del manifiesto sobre los valores por defecto."""
|
|
27
|
+
ruta = os.environ.get("WEBSCOPE_MANIFIESTO")
|
|
28
|
+
propia = json.loads(Path(ruta).read_text()).get("proyecto", {}) if ruta else {}
|
|
29
|
+
return {**DEFECTO, **propia, "carpetas": {**DEFECTO["carpetas"], **propia.get("carpetas", {})}}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def modulo(area_modulo):
|
|
33
|
+
"""Carpeta del módulo `<Area>/<Modulo>` en el backend."""
|
|
34
|
+
return RAIZ / config()["modulos"] / area_modulo
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def consulta(sentencia):
|
|
38
|
+
"""Corre una consulta de solo lectura con el script `sql` del proyecto (imprime TSV con cabecera) y devuelve las filas sin cabecera."""
|
|
39
|
+
if not config()["sql"]:
|
|
40
|
+
sys.exit("ERR el proyecto no define `sql`: ponlo en la sección `proyecto` del manifiesto (script que imprime TSV con cabecera)")
|
|
41
|
+
script = RAIZ / config()["sql"]
|
|
42
|
+
if not script.is_file():
|
|
43
|
+
sys.exit(f"ERR el script `sql` del manifiesto no existe: {script}")
|
|
44
|
+
p = subprocess.run([str(script), sentencia], capture_output=True, text=True, cwd=RAIZ)
|
|
45
|
+
if p.returncode:
|
|
46
|
+
sys.exit(f"ERR {(p.stdout + p.stderr).strip()[-300:]}")
|
|
47
|
+
lineas = [l for l in p.stdout.splitlines() if l and not l.startswith(("== ", "("))]
|
|
48
|
+
return [l.split("\t") for l in lineas[1:]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reglas_back(php):
|
|
52
|
+
"""Devuelve {campo: {required, nullable, max, unique, in}} leído del rules() de un FormRequest."""
|
|
53
|
+
cuerpo = php.split("function rules()", 1)[-1].split("function messages", 1)[0]
|
|
54
|
+
salida = {}
|
|
55
|
+
for campo, lista, texto in re.findall(r"'(\w+)'\s*=>\s*(?:\[(.*?)\]|'([^']*)')\s*,\s*\n", cuerpo, re.S):
|
|
56
|
+
reglas = (lista or "") if lista else texto
|
|
57
|
+
max_ = re.search(r"max:(\d+)", reglas)
|
|
58
|
+
in_ = re.search(r"(?<![\w])in:([\w,]+)", reglas)
|
|
59
|
+
salida[campo] = {"required": "required" in reglas, "nullable": "nullable" in reglas,
|
|
60
|
+
"max": int(max_.group(1)) if max_ else None, "unique": "unique" in reglas,
|
|
61
|
+
"in": in_.group(1).split(",") if in_ else None}
|
|
62
|
+
return salida
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Uso real de subagentes de un proyecto: por tipo, veces, vueltas (media y máxima), contexto escrito y leído, y modelos.
|
|
3
|
+
|
|
4
|
+
Uso: python3 -I uso_agentes.py [--raiz <carpeta del proyecto>] [--max-vueltas 40]
|
|
5
|
+
Lee `~/.claude/projects/<raíz>/*/subagents/agent-*.jsonl` y su `.meta.json`. Marca los subagentes que pasan de --max-vueltas,
|
|
6
|
+
que es donde el contexto crece como en una sesión principal.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def carpeta(argv):
|
|
16
|
+
"""Carpeta de transcripts del proyecto pedido o del directorio actual."""
|
|
17
|
+
raiz = argv[argv.index("--raiz") + 1] if "--raiz" in argv else str(Path.cwd())
|
|
18
|
+
return Path.home() / ".claude/projects" / re.sub(r"[^A-Za-z0-9]", "-", raiz)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def subagente(lineas):
|
|
22
|
+
"""Devuelve (vueltas, escrito, leído, modelos) de un transcript de subagente."""
|
|
23
|
+
vistos, escrito, leido, modelos = set(), 0, 0, set()
|
|
24
|
+
for linea in lineas:
|
|
25
|
+
d = json.loads(linea)
|
|
26
|
+
m = d.get("message") or {}
|
|
27
|
+
if d.get("type") != "assistant" or m.get("id") in vistos:
|
|
28
|
+
continue
|
|
29
|
+
vistos.add(m.get("id"))
|
|
30
|
+
u = m.get("usage") or {}
|
|
31
|
+
escrito += u.get("cache_creation_input_tokens", 0)
|
|
32
|
+
leido += u.get("cache_read_input_tokens", 0)
|
|
33
|
+
if m.get("model", "").startswith("claude-"):
|
|
34
|
+
modelos.add(m["model"].removeprefix("claude-"))
|
|
35
|
+
return len(vistos), escrito, leido, modelos
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def informe(filas, tope):
|
|
39
|
+
"""Texto por tipo de agente y lista de subagentes que pasaron el tope de vueltas; filas = [(tipo, descripción, vueltas, escrito, leído, modelos)]."""
|
|
40
|
+
por = defaultdict(lambda: [0, 0, 0, 0, 0, set()])
|
|
41
|
+
for tipo, _, n, w, r, mods in filas:
|
|
42
|
+
t = por[tipo]
|
|
43
|
+
t[0] += 1
|
|
44
|
+
t[1] += n
|
|
45
|
+
t[2] = max(t[2], n)
|
|
46
|
+
t[3] += w
|
|
47
|
+
t[4] += r
|
|
48
|
+
t[5] |= mods
|
|
49
|
+
total = sum(t[4] for t in por.values()) or 1
|
|
50
|
+
salida = [f"Subagentes: {len(filas)}, {total // 1_000_000} M leídos. Sigue: dividir por módulo los que pasan de {tope} vueltas."]
|
|
51
|
+
for tipo, (k, n, mx, w, r, mods) in sorted(por.items(), key=lambda x: -x[1][4]):
|
|
52
|
+
salida.append(f"tipo {tipo}: {k} veces, vueltas media {n // k} máx {mx}, escrito {w // 1000} k, leído {r // 1000} k "
|
|
53
|
+
f"({100 * r // total} %), modelos {','.join(sorted(mods)) or '?'}")
|
|
54
|
+
for tipo, desc, n, _, r, _ in sorted(filas, key=lambda x: -x[4]):
|
|
55
|
+
if n > tope:
|
|
56
|
+
salida.append(f"pasa de {tope} vueltas: {tipo} «{desc[:60]}» {n} vueltas, {r // 1000} k leídos")
|
|
57
|
+
return "\n".join(salida)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
argv = sys.argv[1:]
|
|
62
|
+
tope = int(argv[argv.index("--max-vueltas") + 1]) if "--max-vueltas" in argv else 40
|
|
63
|
+
filas = []
|
|
64
|
+
for meta in carpeta(argv).glob("*/subagents/agent-*.meta.json"):
|
|
65
|
+
m = json.loads(meta.read_text())
|
|
66
|
+
datos = subagente(meta.with_name(meta.name.replace(".meta.json", ".jsonl")).read_text().splitlines())
|
|
67
|
+
filas.append((m.get("agentType", "?"), m.get("description", ""), *datos))
|
|
68
|
+
print(informe(filas, tope))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cuánto de leer y buscar pasa por webscope en las sesiones de Claude Code de un proyecto, y qué se hace en su lugar.
|
|
3
|
+
|
|
4
|
+
Uso: python3 -I uso_webscope.py [--raiz <carpeta del proyecto>] [--desde AAAA-MM-DD]
|
|
5
|
+
Lee `~/.claude/projects/<raíz>*/*.jsonl` (sesión principal, sin subagentes) y cuenta solo las sesiones donde webscope
|
|
6
|
+
estaba disponible. Lecturas: Read, cat, sed -n, head, tail; búsquedas: grep, rg. Las imágenes no cuentan.
|
|
7
|
+
"""
|
|
8
|
+
import collections
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SHELL = r"\s*(?:cd [^&;]+(?:&&|;)\s*)?"
|
|
15
|
+
IMAGENES = (".png", ".jpg", ".jpeg", ".gif", ".webp")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def clase(nombre, entrada):
|
|
19
|
+
"""Devuelve (grupo, tipo) de una llamada: grupo «leer» o «buscar», o None si no es ninguno de los dos."""
|
|
20
|
+
if nombre.startswith(("mcp__webscope__", "mcp__escueto__")):
|
|
21
|
+
corto = nombre.split("__")[-1]
|
|
22
|
+
return ({"leer": "leer", "buscar": "buscar"}.get(corto), f"webscope {corto}")
|
|
23
|
+
cmd = entrada.get("command", "") if nombre == "Bash" else ""
|
|
24
|
+
if nombre == "Read" and not entrada.get("file_path", "").lower().endswith(IMAGENES):
|
|
25
|
+
return "leer", "Read " + ("rango" if entrada.get("offset") or entrada.get("limit") else "entero")
|
|
26
|
+
if re.match(SHELL + r"sed -n", cmd):
|
|
27
|
+
return "leer", "sed -n"
|
|
28
|
+
if re.match(SHELL + r"(cat|head|tail) ", cmd):
|
|
29
|
+
return "leer", re.match(SHELL + r"(\w+)", cmd)[1]
|
|
30
|
+
if re.match(SHELL + r"(grep|rg) ", cmd):
|
|
31
|
+
return "buscar", "grep"
|
|
32
|
+
return None, None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def contar(lineas, desde=""):
|
|
36
|
+
"""Llamadas por (grupo, tipo) de una sesión desde la fecha `desde` (AAAA-MM-DD) y si webscope estaba disponible
|
|
37
|
+
en ella con alguna línea en esas fechas."""
|
|
38
|
+
cuenta, disponible, reciente = collections.Counter(), False, not desde
|
|
39
|
+
for linea in lineas:
|
|
40
|
+
if "mcp__webscope__" in linea or "mcp__escueto__" in linea:
|
|
41
|
+
disponible = True
|
|
42
|
+
try:
|
|
43
|
+
d = json.loads(linea)
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
if d.get("timestamp", "")[:10] >= desde:
|
|
47
|
+
reciente = True
|
|
48
|
+
elif desde:
|
|
49
|
+
continue
|
|
50
|
+
if d.get("type") != "assistant" or d.get("isSidechain"):
|
|
51
|
+
continue
|
|
52
|
+
for b in (d.get("message") or {}).get("content") or []:
|
|
53
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
54
|
+
grupo, tipo = clase(b.get("name", ""), b.get("input") or {})
|
|
55
|
+
if grupo:
|
|
56
|
+
cuenta[(grupo, tipo)] += 1
|
|
57
|
+
return cuenta, disponible and reciente
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def informe(cuenta, sesiones):
|
|
61
|
+
"""Texto con el % por webscope de leer y de buscar y los tipos de llamada que lo sustituyeron."""
|
|
62
|
+
salida = [f"Sesiones con webscope disponible: {sesiones}. Sigue: lo que más se hace fuera de webscope es lo que falta cubrir."]
|
|
63
|
+
for grupo in ("leer", "buscar"):
|
|
64
|
+
total = sum(n for (g, _), n in cuenta.items() if g == grupo)
|
|
65
|
+
propio = sum(n for (g, t), n in cuenta.items() if g == grupo and t.startswith("webscope"))
|
|
66
|
+
if total:
|
|
67
|
+
otros = sorted(((n, t) for (g, t), n in cuenta.items() if g == grupo and not t.startswith("webscope")), reverse=True)
|
|
68
|
+
salida.append(f"{grupo}: {propio} de {total} por webscope ({100 * propio // total} %); fuera: "
|
|
69
|
+
+ ", ".join(f"{t} {n}" for n, t in otros[:5]))
|
|
70
|
+
return "\n".join(salida)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
argv = sys.argv[1:]
|
|
75
|
+
raiz = argv[argv.index("--raiz") + 1] if "--raiz" in argv else str(Path.cwd())
|
|
76
|
+
desde = argv[argv.index("--desde") + 1] if "--desde" in argv else ""
|
|
77
|
+
prefijo = re.sub(r"[^A-Za-z0-9]", "-", raiz)
|
|
78
|
+
total, sesiones = collections.Counter(), 0
|
|
79
|
+
for carpeta in (Path.home() / ".claude/projects").glob(prefijo + "*"):
|
|
80
|
+
for archivo in carpeta.glob("*.jsonl"):
|
|
81
|
+
cuenta, disponible = contar(archivo.read_text(errors="ignore").splitlines(), desde)
|
|
82
|
+
if disponible:
|
|
83
|
+
total += cuenta
|
|
84
|
+
sesiones += 1
|
|
85
|
+
print(informe(total, sesiones))
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mapa de un módulo de un frontend Vue 3 + TypeScript: rutas, vistas, llamadas HTTP, composables y stores.
|
|
3
|
+
|
|
4
|
+
Uso: python3 -I vue_ts.py <carpeta-de-módulos> <módulo> [--receta] [--recorrido]
|
|
5
|
+
`<módulo>` es una subcadena de la ruta relativa (p. ej. `configs/templates`). Con `--receta` añade los pasos para revisarlo
|
|
6
|
+
en el navegador. Con `--recorrido` cambia la lista de llamadas por lo que se puede pulsar: acciones con su `v-if` y permiso,
|
|
7
|
+
enlaces, pestañas con su componente y API, campos de formulario y estados. Con `--cobertura <captura>` cuenta qué llamadas
|
|
8
|
+
del módulo aparecen en una captura de `e2e_chrome --guardar` y lista las que faltan. Cachea el índice en `~/.cache/webscope/` y lo rehace solo si cambió algún archivo.
|
|
9
|
+
"""
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
RUTA = re.compile(r"path:\s*['\"]([^'\"]+)['\"]")
|
|
17
|
+
NOMBRE = re.compile(r"name:\s*['\"]([^'\"]+)['\"]")
|
|
18
|
+
VISTA = re.compile(r"import\(['\"]([^'\"]+\.vue)['\"]\)")
|
|
19
|
+
HTTP = re.compile(r"\b(?:axios|http|api)\.(get|post|put|patch|delete)(?:<[^>]*>)?\(\s*([`'\"])(.+?)\2")
|
|
20
|
+
ETIQUETA = re.compile(r"<([A-Za-z][\w-]*)((?:\s+[^\s=>/\"']+(?:=(?:\"[^\"]*\"|'[^']*'))?)*)\s*/?>([^<]*)")
|
|
21
|
+
ATRIBUTO = re.compile(r"([^\s=>/\"']+)(?:=(?:\"([^\"]*)\"|'([^']*)'))?")
|
|
22
|
+
IMPORTA = re.compile(r"import\s+(\w+)\s+from\s+['\"]([^'\"]+\.vue)['\"]")
|
|
23
|
+
COMPOSABLE = re.compile(r"from\s+['\"]([^'\"]*/composables/\w+)['\"]")
|
|
24
|
+
ESTADO = re.compile(r"^\s*([A-Z][A-Z_]+):", re.M)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def indexar(base):
|
|
28
|
+
"""Devuelve {módulo: {rutas, llamadas, composables, stores}} recorriendo `base`; el módulo es su carpeta de primer nivel con subcarpeta."""
|
|
29
|
+
mapa = {}
|
|
30
|
+
for f in sorted(base.rglob("*")):
|
|
31
|
+
if f.suffix not in (".ts", ".vue") or "node_modules" in f.parts:
|
|
32
|
+
continue
|
|
33
|
+
rel = f.relative_to(base)
|
|
34
|
+
modulo = "/".join(rel.parts[:2]) if len(rel.parts) > 2 else rel.parts[0]
|
|
35
|
+
m = mapa.setdefault(modulo, {"rutas": [], "llamadas": [], "composables": [], "stores": []})
|
|
36
|
+
texto = f.read_text(errors="ignore")
|
|
37
|
+
if f.name.endswith("Routes.ts"):
|
|
38
|
+
bloques = texto.split("{")
|
|
39
|
+
previas = bloques[0].count("\n")
|
|
40
|
+
for bloque in bloques[1:]:
|
|
41
|
+
p, n, v = RUTA.search(bloque), NOMBRE.search(bloque), VISTA.search(bloque)
|
|
42
|
+
if p:
|
|
43
|
+
m["rutas"].append({"path": p.group(1), "name": n and n.group(1), "vista": v and v.group(1),
|
|
44
|
+
"en": f"{rel}:{previas + 1 + bloque[:p.start()].count(chr(10))}"})
|
|
45
|
+
previas += bloque.count("\n")
|
|
46
|
+
if "composables" in rel.parts:
|
|
47
|
+
m["composables"].append(str(rel))
|
|
48
|
+
if "stores" in rel.parts:
|
|
49
|
+
m["stores"].append(str(rel))
|
|
50
|
+
for i, linea in enumerate(texto.splitlines(), 1):
|
|
51
|
+
for metodo, _, url in HTTP.findall(linea):
|
|
52
|
+
m["llamadas"].append(f"{metodo.upper()} {url} {rel}:{i}")
|
|
53
|
+
return mapa
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cargado(base):
|
|
57
|
+
"""Índice de `base` desde la caché, rehecho si cambió el tamaño o la fecha de algún archivo."""
|
|
58
|
+
firma = hashlib.md5(("v2" + "".join(f"{f}{f.stat().st_mtime_ns}{f.stat().st_size}" for f in sorted(base.rglob("*.[tv][su]*"))
|
|
59
|
+
if "node_modules" not in f.parts)).encode()).hexdigest()
|
|
60
|
+
caché = Path.home() / ".cache/webscope" / (hashlib.md5(str(base).encode()).hexdigest() + ".json")
|
|
61
|
+
if caché.exists():
|
|
62
|
+
datos = json.loads(caché.read_text())
|
|
63
|
+
if datos["firma"] == firma:
|
|
64
|
+
return datos["mapa"]
|
|
65
|
+
mapa = indexar(base)
|
|
66
|
+
caché.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
caché.write_text(json.dumps({"firma": firma, "mapa": mapa}))
|
|
68
|
+
return mapa
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def relativo(base, archivo, spec):
|
|
72
|
+
"""Ruta de un import (`@/…` o relativo a `archivo`) relativa a `base`, o None si cae fuera."""
|
|
73
|
+
if spec.startswith("@/"):
|
|
74
|
+
destino = base.parent / spec[2:]
|
|
75
|
+
elif spec.startswith("."):
|
|
76
|
+
destino = archivo.parent / spec
|
|
77
|
+
else:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
return str(destino.resolve().relative_to(base.resolve()))
|
|
81
|
+
except ValueError:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resumen(base, archivo, texto, por_api):
|
|
86
|
+
"""`useX: GET×3 POST` por cada composable que importa `texto` y hace llamadas HTTP."""
|
|
87
|
+
partes = []
|
|
88
|
+
for spec in dict.fromkeys(COMPOSABLE.findall(texto)):
|
|
89
|
+
rel = relativo(base, archivo, spec)
|
|
90
|
+
metodos = por_api.get(f"{rel}.ts", []) if rel else []
|
|
91
|
+
if metodos:
|
|
92
|
+
cuenta = {m: metodos.count(m) for m in dict.fromkeys(metodos)}
|
|
93
|
+
partes.append(Path(spec).name + ": " + " ".join(m + (f"×{n}" if n > 1 else "") for m, n in cuenta.items()))
|
|
94
|
+
return "; ".join(partes)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def recorrido(base, mapa, modulo):
|
|
98
|
+
"""Acciones con su condición, permisos, enlaces, pestañas con su componente y API, campos y estados de los archivos de `modulo`."""
|
|
99
|
+
# shortcut: no hereda el v-if de un <template> padre, un v-window anidado cierra la pestaña y los imports con llaves no dan ruta; mejorar si el recorrido en vivo falla por eso.
|
|
100
|
+
por_api = {}
|
|
101
|
+
for m in mapa.values():
|
|
102
|
+
for c in m["llamadas"]:
|
|
103
|
+
por_api.setdefault(c.rsplit(" ", 1)[1].rsplit(":", 1)[0], []).append(c.split()[0])
|
|
104
|
+
salida = []
|
|
105
|
+
for f in sorted((base / modulo).rglob("*")):
|
|
106
|
+
rel = f.relative_to(base)
|
|
107
|
+
if f.suffix == ".ts" and "Status" in f.name:
|
|
108
|
+
estados = list(dict.fromkeys(ESTADO.findall(f.read_text(errors="ignore"))))
|
|
109
|
+
if estados:
|
|
110
|
+
salida.append(f"estados {' '.join(estados)} {rel}")
|
|
111
|
+
if f.suffix != ".vue":
|
|
112
|
+
continue
|
|
113
|
+
texto = f.read_text(errors="ignore")
|
|
114
|
+
ini, fin = texto.find("<template"), texto.rfind("</template>")
|
|
115
|
+
if ini < 0:
|
|
116
|
+
continue
|
|
117
|
+
plantilla = texto[ini:fin]
|
|
118
|
+
importa = {n: relativo(base, f, s) for n, s in IMPORTA.findall(texto)}
|
|
119
|
+
cierres = [c.start() for c in re.finditer(r"</v-window>", plantilla)]
|
|
120
|
+
lineas, campos, pestaña = [], [], None
|
|
121
|
+
for t in ETIQUETA.finditer(plantilla):
|
|
122
|
+
nombre, n = t.group(1), texto.count("\n", 0, ini + t.start()) + 1
|
|
123
|
+
atributos = list(ATRIBUTO.finditer(t.group(2)))
|
|
124
|
+
a = {x.group(1): x.group(2) if x.group(2) is not None else (x.group(3) or "") for x in atributos}
|
|
125
|
+
clic = next((v for k, v in a.items() if k.split(".")[0] in ("@click", "v-on:click")), None)
|
|
126
|
+
if pestaña and any(pestaña["desde"] < c < t.start() for c in cierres):
|
|
127
|
+
pestaña = None
|
|
128
|
+
if nombre == "v-window-item":
|
|
129
|
+
pestaña = {"desde": t.start(), "linea": n, "valor": a.get("value") or a.get(":value"), "comps": []}
|
|
130
|
+
lineas.append(pestaña)
|
|
131
|
+
elif pestaña and nombre[0].isupper():
|
|
132
|
+
pestaña["comps"].append(nombre)
|
|
133
|
+
if clic:
|
|
134
|
+
etiqueta = a.get(":title") or a.get("title") or a.get("icon") or t.group(3).strip()
|
|
135
|
+
lineas.append(f"accion {n} {clic}" + (f" si {a['v-if']}" if "v-if" in a else "") + (f" «{etiqueta}»" if etiqueta else ""))
|
|
136
|
+
lineas += [f"permiso {texto.count(chr(10), 0, ini + t.start(2) + x.start()) + 1} {k}={a[k]}" for x in atributos
|
|
137
|
+
if "can(" in a[(k := x.group(1))] and not (clic and k == "v-if")]
|
|
138
|
+
if nombre == "AppLink":
|
|
139
|
+
lineas.append(f"enlace {n} {a.get(':to') or a.get('to')}")
|
|
140
|
+
if ":label" in a or "label" in a:
|
|
141
|
+
valor = a.get(":label") or a["label"]
|
|
142
|
+
i18n = re.search(r"\$?\bt\(\s*['\"]([^'\"]+)", valor)
|
|
143
|
+
clave = (i18n.group(1) if i18n else valor).split(".")[-1]
|
|
144
|
+
campos.append((n, clave + ("*" if "required" in a or "required" in a.get(":rules", "") else "")))
|
|
145
|
+
if campos:
|
|
146
|
+
lineas.append(f"campos {campos[0][0]} " + ", ".join(c for _, c in campos))
|
|
147
|
+
api = resumen(base, f, texto, por_api)
|
|
148
|
+
if lineas or api:
|
|
149
|
+
salida.append(f"### {rel}" + (f" [{api}]" if api else ""))
|
|
150
|
+
for linea in lineas:
|
|
151
|
+
if isinstance(linea, dict):
|
|
152
|
+
comps = []
|
|
153
|
+
for c in dict.fromkeys(linea["comps"]):
|
|
154
|
+
destino = base / importa[c] if importa.get(c) else None
|
|
155
|
+
api = resumen(base, destino, destino.read_text(errors="ignore"), por_api) if destino and destino.exists() else ""
|
|
156
|
+
comps.append(c + (f" {importa[c]}" if destino else "") + (f" [{api}]" if api else ""))
|
|
157
|
+
linea = f"tab {linea['linea']} {linea['valor']} -> " + ", ".join(comps)
|
|
158
|
+
salida.append(linea)
|
|
159
|
+
return salida
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def patron(url):
|
|
163
|
+
"""Regex de una URL del front: `${x}` vale un segmento y una `/` final admite el resto."""
|
|
164
|
+
cuerpo = "[^/]+".join(re.escape(p) for p in re.split(r"\$\{[^}]*\}", url))
|
|
165
|
+
return re.compile(cuerpo + (".+" if url.endswith("/") else "") + "$")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cobertura(mapa, filtro, captura):
|
|
169
|
+
"""Llamadas del mapa de los módulos con `filtro` que aparecen en una captura (`método estado ruta ...` por línea)."""
|
|
170
|
+
hechas = [(p[0], p[2].split("?")[0]) for p in (l.split() for l in Path(captura).read_text().splitlines()) if len(p) > 2]
|
|
171
|
+
llamadas = [c.split()[:2] for n, m in mapa.items() if filtro in n for c in m["llamadas"]]
|
|
172
|
+
faltan = [f"{m} {u}" for m, u in llamadas if not any(h == m and patron(u).match(r) for h, r in hechas)]
|
|
173
|
+
return "\n".join([f"cobertura {len(llamadas) - len(faltan)}/{len(llamadas)} de «{filtro}» en {captura}. Sigue: ejercer las que faltan."]
|
|
174
|
+
+ [f"falta {f}" for f in faltan])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def mostrar(mapa, filtro, receta, base=None):
|
|
178
|
+
"""Texto con los módulos cuyo nombre contiene `filtro`; con `base`, el recorrido en lugar de la lista de llamadas."""
|
|
179
|
+
salida = [f"Frontend de «{filtro}»: rutas (pantallas), api (llamadas al backend), composables y stores. Sigue: mapa_back."]
|
|
180
|
+
for nombre, m in mapa.items():
|
|
181
|
+
if filtro not in nombre:
|
|
182
|
+
continue
|
|
183
|
+
salida.append(f"## {nombre}")
|
|
184
|
+
salida += [f"ruta {r['path']} ({r['name']}) -> {r['vista']} {r['en']}" for r in m["rutas"]]
|
|
185
|
+
if base:
|
|
186
|
+
for g in sorted(Path(".claude/webscope/recorridos").glob(f"{nombre.replace('/', '-')}*.json")):
|
|
187
|
+
salida.append(f"recorrido guardado {g} ({len(json.loads(g.read_text()))} pasos): repítelo antes de explorar "
|
|
188
|
+
f"con e2e_chrome --repetir {g.stem}")
|
|
189
|
+
salida.append(f"api total {len(m['llamadas'])} (cobertura = ejercidas / total)")
|
|
190
|
+
salida += recorrido(base, mapa, nombre)
|
|
191
|
+
continue
|
|
192
|
+
salida += [f"api {c}" for c in m["llamadas"]]
|
|
193
|
+
salida += [f"composable {c}" for c in m["composables"]] + [f"store {s}" for s in m["stores"]]
|
|
194
|
+
if receta:
|
|
195
|
+
sin_param = [r["path"] for r in m["rutas"] if ":" not in r["path"]]
|
|
196
|
+
salida.append("receta: correr(e2e_chrome, " + " ".join(sin_param) + ") da el JavaScript; pégalo en UNA llamada a "
|
|
197
|
+
"javascript_tool, sin capturas ni esperas aparte. Captura solo si el JSON marca un problema.")
|
|
198
|
+
return "\n".join(salida) if len(salida) > 1 else f"sin módulo que contenga «{filtro}»"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
if len(sys.argv) < 3:
|
|
203
|
+
sys.exit(__doc__)
|
|
204
|
+
base = Path(sys.argv[1]).resolve()
|
|
205
|
+
if "--cobertura" in sys.argv:
|
|
206
|
+
sys.exit(print(cobertura(cargado(base), sys.argv[2], sys.argv[sys.argv.index("--cobertura") + 1])))
|
|
207
|
+
print(mostrar(cargado(base), sys.argv[2], "--receta" in sys.argv, base if "--recorrido" in sys.argv else None))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* Chequeo de UI para pegar en `javascript_tool` (Claude in Chrome) o `page.evaluate`: devuelve solo los problemas, una línea cada uno. */
|
|
2
|
+
(() => {
|
|
3
|
+
const problemas = [];
|
|
4
|
+
const visible = (el) => {
|
|
5
|
+
const r = el.getBoundingClientRect();
|
|
6
|
+
const s = getComputedStyle(el);
|
|
7
|
+
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0' &&
|
|
8
|
+
!el.closest('.v-overlay:not(.v-overlay--active)');
|
|
9
|
+
};
|
|
10
|
+
const corto = (t) => t.replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
11
|
+
const hojas = [...document.body.querySelectorAll('*')].filter((el) => visible(el) && ![...el.children].some(visible) && corto(el.textContent || ''));
|
|
12
|
+
|
|
13
|
+
if (document.documentElement.scrollWidth > innerWidth + 1) {
|
|
14
|
+
problemas.push(`DESBORDE: scroll horizontal (${document.documentElement.scrollWidth}px en ${innerWidth}px)`);
|
|
15
|
+
}
|
|
16
|
+
for (const el of document.querySelectorAll('[class*="chip"],[class*="badge"]')) {
|
|
17
|
+
if (visible(el) && !/underlay|overlay/.test(String(el.className)) && !corto(el.textContent || '') && !el.querySelector('svg,img,i,[class*="icon"]')) {
|
|
18
|
+
problemas.push(`CHIP VACÍO: <${el.tagName.toLowerCase()} class="${String(el.className).slice(0, 40)}">`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
for (const el of hojas) {
|
|
22
|
+
const t = corto(el.textContent);
|
|
23
|
+
if (/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+){1,}$/i.test(t) && !/\.(com|org|net|io|co|png|jpg|svg|pdf)$/i.test(t)) problemas.push(`CLAVE SIN TRADUCIR: "${t}"`);
|
|
24
|
+
if (/^(undefined|null|NaN|\[object Object\]|Invalid Date)$/.test(t)) problemas.push(`VALOR CRUDO: "${t}"`);
|
|
25
|
+
}
|
|
26
|
+
const caja = hojas.slice(0, 400).map((el) => [el, el.getBoundingClientRect()]);
|
|
27
|
+
for (let i = 0; i < caja.length; i++) {
|
|
28
|
+
for (let j = i + 1; j < caja.length; j++) {
|
|
29
|
+
const [a, ra] = caja[i];
|
|
30
|
+
const [b, rb] = caja[j];
|
|
31
|
+
if (a.contains(b) || b.contains(a) || corto(a.textContent) === corto(b.textContent)) continue;
|
|
32
|
+
const w = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
|
|
33
|
+
const h = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
|
34
|
+
if (w > 2 && h > 2 && (w * h) / Math.min(ra.width * ra.height, rb.width * rb.height) > 0.3) {
|
|
35
|
+
problemas.push(`SOLAPA: "${corto(a.textContent)}" con "${corto(b.textContent)}"`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const el of document.querySelectorAll('button,a[href],[role="button"]')) {
|
|
40
|
+
if (visible(el) && !corto(el.textContent || '') && !el.getAttribute('aria-label') && !el.getAttribute('title')) {
|
|
41
|
+
problemas.push(`BOTÓN SIN NOMBRE: <${el.tagName.toLowerCase()} class="${String(el.className).slice(0, 40)}">`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return problemas.length ? problemas.slice(0, 30) : ['OK: sin problemas'];
|
|
45
|
+
})();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Cuerpo del e2e en Chrome: recorre RUTAS dentro de la app con el router (sin recargar, sin captura) y devuelve un JSON corto por ruta.
|
|
2
|
+
* `adaptadores/e2e_chrome.py` antepone `const RUTAS=[...]` y `const chequeo=...` y lo entrega listo para pegar en `javascript_tool`. */
|
|
3
|
+
(async () => {
|
|
4
|
+
const enrutador = () => document.querySelector('#app')?.__vue_app__?.config.globalProperties.$router;
|
|
5
|
+
const est = window.__e2e || (window.__e2e = { red: [], consola: [], pendientes: 0 });
|
|
6
|
+
if (!est.listo) {
|
|
7
|
+
est.listo = true;
|
|
8
|
+
est.detalle = [];
|
|
9
|
+
const SECRETO = /pass|token|secret|clave|authorization|api[_-]?key/i;
|
|
10
|
+
const forma = (v, d = 0) => {
|
|
11
|
+
if (Array.isArray(v)) return { n: v.length, claves: v[0] && typeof v[0] === 'object' ? Object.keys(v[0]) : undefined };
|
|
12
|
+
if (v && typeof v === 'object') {
|
|
13
|
+
if (d > 1) return Object.keys(v);
|
|
14
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, SECRETO.test(k) ? '***' : forma(x, d + 1)]));
|
|
15
|
+
}
|
|
16
|
+
return typeof v === 'string' ? v.slice(0, 60) : v;
|
|
17
|
+
};
|
|
18
|
+
const resumen = (texto) => {
|
|
19
|
+
if (texto == null || texto === '') return null;
|
|
20
|
+
if (typeof texto !== 'string') return String(texto).slice(0, 40);
|
|
21
|
+
try { const s = JSON.stringify(forma(JSON.parse(texto))); return JSON.parse(s.length > 700 ? JSON.stringify(s.slice(0, 700)) : s); } catch { return texto.slice(0, 120); }
|
|
22
|
+
};
|
|
23
|
+
const rutaDe = (url) => String(url).replace(/^https?:\/\/[^/]+/, '').split('?')[0].slice(0, 70);
|
|
24
|
+
const fin = (estado, url, metodo, cuerpo, respuesta, t0) => {
|
|
25
|
+
est.pendientes--;
|
|
26
|
+
est.red.push([estado, rutaDe(url)]);
|
|
27
|
+
if (String(url).includes('/api/')) {
|
|
28
|
+
const d = { m: metodo, u: rutaDe(url), s: estado, ms: Math.round(performance.now() - t0), q: typeof cuerpo === 'string' ? resumen(cuerpo) : cuerpo ? '[binario]' : null, r: resumen(respuesta) };
|
|
29
|
+
// La primera escritura tras pulsar guardar lleva los campos del formulario: {etiqueta: [antes, después, visto]}.
|
|
30
|
+
if (metodo !== 'GET' && est.formPend) { d.f = est.formPend; est.formPend = null; }
|
|
31
|
+
est.detalle.push(d);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const f0 = window.fetch;
|
|
35
|
+
window.fetch = async (...a) => {
|
|
36
|
+
est.pendientes++;
|
|
37
|
+
const url = a[0]?.url || a[0];
|
|
38
|
+
const metodo = String(a[1]?.method || a[0]?.method || 'GET').toUpperCase();
|
|
39
|
+
const t0 = performance.now();
|
|
40
|
+
try { const r = await f0(...a); fin(r.status, url, metodo, a[1]?.body, await r.clone().text(), t0); return r; } catch (e) { fin('ERR', url, metodo, a[1]?.body, null, t0); throw e; }
|
|
41
|
+
};
|
|
42
|
+
const abrir = XMLHttpRequest.prototype.open;
|
|
43
|
+
const enviar = XMLHttpRequest.prototype.send;
|
|
44
|
+
XMLHttpRequest.prototype.open = function (m, u) { this._u = u; this._m = String(m).toUpperCase(); return abrir.apply(this, arguments); };
|
|
45
|
+
XMLHttpRequest.prototype.send = function (cuerpo) {
|
|
46
|
+
est.pendientes++;
|
|
47
|
+
const t0 = performance.now();
|
|
48
|
+
this.addEventListener('loadend', () => fin(this.status || 'ERR', this._u, this._m, cuerpo, this.responseType === 'json' ? JSON.stringify(this.response) : this.responseType === '' || this.responseType === 'text' ? this.responseText : '[binario]', t0));
|
|
49
|
+
return enviar.apply(this, arguments);
|
|
50
|
+
};
|
|
51
|
+
const err = console.error;
|
|
52
|
+
console.error = (...a) => { est.consola.push(a.map(String).join(' ').slice(0, 120)); err(...a); };
|
|
53
|
+
addEventListener('error', (e) => est.consola.push(String(e.message).slice(0, 120)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
est.chequeo ||= chequeo;
|
|
57
|
+
// El recorrido queda en la página como `window.__e2e.run(rutas)`: las llamadas siguientes no repiten este código.
|
|
58
|
+
est.run ||= async (RUTAS) => {
|
|
59
|
+
const estable = async () => {
|
|
60
|
+
let ultimo = performance.now();
|
|
61
|
+
const obs = new MutationObserver(() => { ultimo = performance.now(); });
|
|
62
|
+
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
63
|
+
const limite = performance.now() + 8000;
|
|
64
|
+
while (performance.now() < limite) {
|
|
65
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
66
|
+
if (est.pendientes <= 0 && performance.now() - ultimo > 500) break;
|
|
67
|
+
}
|
|
68
|
+
obs.disconnect();
|
|
69
|
+
return performance.now() < limite;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Una línea por tipo de problema: «TIPO xN: primer ejemplo».
|
|
73
|
+
const resumir = (lineas) => {
|
|
74
|
+
const tipos = {};
|
|
75
|
+
for (const l of lineas) {
|
|
76
|
+
const k = l.split(':')[0];
|
|
77
|
+
(tipos[k] ||= { n: 0, ej: l.slice(k.length + 2, 90) }).n++;
|
|
78
|
+
}
|
|
79
|
+
return Object.entries(tipos).map(([k, v]) => (k === 'OK' ? 'OK' : `${k} x${v.n}: ${v.ej}`));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// El avance se guarda en sessionStorage: si la página se recarga a mitad (p. ej. Vite en desarrollo), repetir la llamada continúa.
|
|
83
|
+
const clave = 'e2e-webscope';
|
|
84
|
+
let guardado = null;
|
|
85
|
+
try { guardado = JSON.parse(sessionStorage.getItem(clave) || 'null'); } catch { /* sin almacenamiento */ }
|
|
86
|
+
if (!guardado || guardado.rutas !== RUTAS.join()) guardado = { rutas: RUTAS.join(), informe: [], enCurso: null };
|
|
87
|
+
const informe = guardado.informe;
|
|
88
|
+
const guardar = () => { try { sessionStorage.setItem(clave, JSON.stringify(guardado)); } catch { /* sin almacenamiento */ } };
|
|
89
|
+
for (const ruta of RUTAS) {
|
|
90
|
+
if (informe.some((i) => i.ruta === ruta)) continue;
|
|
91
|
+
const recargo = guardado.enCurso === ruta;
|
|
92
|
+
est.red.length = 0;
|
|
93
|
+
est.consola.length = 0;
|
|
94
|
+
const t0 = performance.now();
|
|
95
|
+
guardado.enCurso = ruta;
|
|
96
|
+
guardar();
|
|
97
|
+
if (!recargo) {
|
|
98
|
+
try { await enrutador().push(ruta); } catch (e) { informe.push({ ruta, error: String(e).slice(0, 100) }); guardado.enCurso = null; guardar(); continue; }
|
|
99
|
+
}
|
|
100
|
+
const quieta = await estable();
|
|
101
|
+
informe.push({
|
|
102
|
+
ruta,
|
|
103
|
+
en: location.pathname,
|
|
104
|
+
ms: Math.round(performance.now() - t0),
|
|
105
|
+
...(recargo ? { recargoLaPagina: true } : {}),
|
|
106
|
+
...(quieta ? {} : { sinEstabilizar: true }),
|
|
107
|
+
titulo: (document.querySelector('h1,h2,.v-toolbar-title')?.textContent || '').trim().slice(0, 40),
|
|
108
|
+
filas: document.querySelectorAll('tbody tr').length,
|
|
109
|
+
llamadas: est.red.length,
|
|
110
|
+
malas: est.red.filter(([s]) => s === 'ERR' || s >= 400),
|
|
111
|
+
consola: est.consola.slice(0, 5),
|
|
112
|
+
ui: resumir(est.chequeo()),
|
|
113
|
+
});
|
|
114
|
+
guardado.enCurso = null;
|
|
115
|
+
guardar();
|
|
116
|
+
}
|
|
117
|
+
try { sessionStorage.removeItem(clave); } catch { /* sin almacenamiento */ }
|
|
118
|
+
return JSON.stringify(informe);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (RUTAS.length === 0) return JSON.stringify('capturando: haz el flujo y pide --detalle');
|
|
122
|
+
if (!enrutador()) return JSON.stringify({ error: 'sin router Vue en #app: abre primero una pantalla de la app' });
|
|
123
|
+
return await est.run(RUTAS);
|
|
124
|
+
})()
|