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.
@@ -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
+ })()