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,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mapa de una pantalla o ruta del backend Laravel: endpoint, controlador, servicio, modelo y tabla, cruzado con las llamadas del frontend.
|
|
3
|
+
|
|
4
|
+
Uso: python3 -I laravel.py <carpeta-backend> <carpeta-módulos-frontend> <módulo del front o fragmento de ruta API> [--obligatorios <captura>]
|
|
5
|
+
Con `--obligatorios`, compara los campos que exige el backend en cada guardado de la captura con los que marca el formulario.
|
|
6
|
+
Lee las rutas de `php artisan route:list --json` en el contenedor Docker (WEBSCOPE_CONTENEDOR o `proyecto.contenedor` del
|
|
7
|
+
manifiesto); sin contenedor, o si no responde, las deduce de los archivos app/Modules/**/Routes/*.php.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
import proyecto # noqa: E402
|
|
18
|
+
import vue_ts # noqa: E402
|
|
19
|
+
|
|
20
|
+
RECURSO = {"index": ("GET", ""), "store": ("POST", ""), "show": ("GET", "/{}"), "update": ("PUT", "/{}"), "destroy": ("DELETE", "/{}")}
|
|
21
|
+
RUTA = re.compile(r"Route::(get|post|put|patch|delete|apiResource)\(\s*'([^']*)'\s*,\s*\[?\s*([\w\\]+)::class(?:\s*,\s*'(\w+)')?[^;]*;", re.S)
|
|
22
|
+
PREFIJO = re.compile(r"Route::prefix\('([^']+)'\)[^;{]*?->group\(\s*function\s*\(\)\s*\{", re.S)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normaliza(uri):
|
|
26
|
+
"""La dirección sin `api/`, sin `?`, sin `/` inicial y con cada parámetro como `{}`."""
|
|
27
|
+
return re.sub(r"\$\{[^}]*\}|\{[^}]*\}", "{}", uri.split("?")[0]).strip("/").removeprefix("api/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rutas_artisan(contenedor):
|
|
31
|
+
"""Rutas de `route:list --json` dentro del contenedor, o None si Docker o el contenedor no responden."""
|
|
32
|
+
try:
|
|
33
|
+
proc = subprocess.run(["docker", "exec", contenedor, "php", "artisan", "route:list", "--json"], capture_output=True, text=True,
|
|
34
|
+
timeout=90, stdin=subprocess.DEVNULL)
|
|
35
|
+
filas = json.loads(proc.stdout)
|
|
36
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
return [{"metodo": f["method"].split("|")[0], "uri": normaliza(f["uri"]), "accion": f["action"]}
|
|
39
|
+
for f in filas if "@" in f["action"] or "\\" in f["action"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rutas_archivos(backend):
|
|
43
|
+
"""Rutas deducidas de Routes/*.php.
|
|
44
|
+
|
|
45
|
+
shortcut: parámetros de apiResource como `{}`, grupos solo con Route::prefix, `api/` supuesto; mejorar si hay rutas fuera de ese patrón.
|
|
46
|
+
"""
|
|
47
|
+
rutas = []
|
|
48
|
+
for f in sorted((backend / "app/Modules").rglob("Routes/*.php")):
|
|
49
|
+
texto = f.read_text(errors="ignore")
|
|
50
|
+
usos = {m[1].split("\\")[-1]: m[1] for m in re.finditer(r"^use ([\w\\]+);", texto, re.M)}
|
|
51
|
+
tramos = []
|
|
52
|
+
for m in PREFIJO.finditer(texto):
|
|
53
|
+
nivel, j = 1, m.end()
|
|
54
|
+
while j < len(texto) and nivel:
|
|
55
|
+
nivel += (texto[j] == "{") - (texto[j] == "}")
|
|
56
|
+
j += 1
|
|
57
|
+
tramos.append((m.start(), j, m[1].strip("/")))
|
|
58
|
+
for m in RUTA.finditer(texto):
|
|
59
|
+
tipo, uri, clase, metodo = m[1], m[2].strip("/"), usos.get(m[3].split("\\")[-1], m[3]), m[4]
|
|
60
|
+
uri = "/".join([p for a, b, p in tramos if a < m.start() < b] + ([uri] if uri else []))
|
|
61
|
+
if tipo == "apiResource":
|
|
62
|
+
limite = re.search(r"->(only|except)\(\[([^\]]*)\]\)", m[0])
|
|
63
|
+
nombres = set(re.findall(r"'(\w+)'", limite[2])) if limite else set()
|
|
64
|
+
for nombre, (verbo, sufijo) in RECURSO.items():
|
|
65
|
+
if not limite or (nombre in nombres) == (limite[1] == "only"):
|
|
66
|
+
rutas.append({"metodo": verbo, "uri": normaliza(uri + sufijo), "accion": f"{clase}@{nombre}", "en": f"{f}"})
|
|
67
|
+
else:
|
|
68
|
+
rutas.append({"metodo": tipo.upper(), "uri": normaliza(uri), "accion": f"{clase}@{metodo or '__invoke'}", "en": f"{f}"})
|
|
69
|
+
return rutas
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def archivo_de(backend, clase):
|
|
73
|
+
"""Ruta del archivo PHP de la clase `App\\...\\Nombre`, o None si no existe."""
|
|
74
|
+
ruta = backend / (clase.replace("App\\", "app/", 1).replace("\\", "/") + ".php")
|
|
75
|
+
return ruta if ruta.is_file() else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tabla_de(modelo):
|
|
79
|
+
"""Valor de `$table` en el archivo del modelo, o None."""
|
|
80
|
+
m = re.search(r"\$table\s*=\s*['\"]([^'\"]+)['\"]", modelo.read_text(errors="ignore"))
|
|
81
|
+
return m and m[1]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def importa(archivo, carpeta):
|
|
85
|
+
"""Clases importadas con `use` cuyo espacio de nombres contiene `\\carpeta\\`."""
|
|
86
|
+
return re.findall(rf"^use ([\w\\]*\\{carpeta}\\[\w\\]+)(?: as \w+)?;", archivo.read_text(errors="ignore"), re.M)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
REGLA = re.compile(r"""['"]([\w.*]+)['"]\s*=>\s*(\[[^\]]*\]|'[^']*'|"[^"]*")""")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def requeridos(texto):
|
|
93
|
+
"""Claves de primer nivel con la regla `required` (no `required_if` ni otras condicionales) en un texto de reglas."""
|
|
94
|
+
return [k for k, v in REGLA.findall(texto) if "." not in k and re.search(r"""['"|]required['"|]""", v)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def obligatorios_de(backend, accion):
|
|
98
|
+
"""Claves obligatorias que valida `Clase@metodo`: las del Request tipado en la firma o las de `$request->validate([...])`."""
|
|
99
|
+
clase, _, metodo = accion.partition("@")
|
|
100
|
+
archivo = archivo_de(backend, clase)
|
|
101
|
+
if not archivo:
|
|
102
|
+
return None
|
|
103
|
+
texto = archivo.read_text(errors="ignore")
|
|
104
|
+
cuerpo = re.search(rf"function {metodo}\(([^)]*)\)(.*?)(?=\n\s*(?:public|protected|private) function|\Z)", texto, re.S)
|
|
105
|
+
if not cuerpo:
|
|
106
|
+
return None
|
|
107
|
+
tipo = re.search(r"(\w+Request)\s+\$\w+", cuerpo[1])
|
|
108
|
+
if tipo:
|
|
109
|
+
usada = re.search(rf"^use ([\w\\]+\\{tipo[1]});", texto, re.M)
|
|
110
|
+
req = archivo_de(backend, usada[1]) if usada else None
|
|
111
|
+
if req:
|
|
112
|
+
reglas = re.search(r"function rules\(.*?(?=\n\s*(?:public|protected|private) function|\Z)", req.read_text(errors="ignore"), re.S)
|
|
113
|
+
return requeridos(reglas[0] if reglas else "")
|
|
114
|
+
validar = re.search(r"validate\(\s*\[(.*?)\]\s*\)", cuerpo[2], re.S)
|
|
115
|
+
return requeridos(validar[1]) if validar else []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cruce_obligatorios(backend, rutas, captura):
|
|
119
|
+
"""Compara, por cada guardado de la captura, los obligatorios del backend con los marcados en el formulario.
|
|
120
|
+
|
|
121
|
+
Empareja cada etiqueta con la clave del cuerpo enviado que lleva el mismo valor; lo que no empareja se dice, no se adivina."""
|
|
122
|
+
salida = []
|
|
123
|
+
for linea in Path(captura).read_text(errors="ignore").splitlines():
|
|
124
|
+
p = linea.split(" ")
|
|
125
|
+
if len(p) < 3 or p[0] not in ("POST", "PUT", "PATCH") or " f {" not in linea:
|
|
126
|
+
continue
|
|
127
|
+
uri = normaliza(p[2])
|
|
128
|
+
ruta = next((r for r in rutas if r["metodo"] == p[0] and re.fullmatch(re.escape(r["uri"]).replace(r"\{\}", r"[^/]+"), uri)), None)
|
|
129
|
+
if not ruta:
|
|
130
|
+
salida.append(f"{p[0]} {uri}: sin ruta en el backend")
|
|
131
|
+
continue
|
|
132
|
+
back = obligatorios_de(backend, ruta["accion"])
|
|
133
|
+
if back is None:
|
|
134
|
+
salida.append(f"{p[0]} {uri}: no encontré el método {ruta['accion']}")
|
|
135
|
+
continue
|
|
136
|
+
q = dict(re.findall(r"(\w+):\w+\((.*?)\)(?= \w+:|$)", linea.split(" q ", 1)[1].split(" r ", 1)[0])) if " q " in linea else {}
|
|
137
|
+
form = json.loads(linea.split(" f ", 1)[1])
|
|
138
|
+
clave = {}
|
|
139
|
+
for etiqueta, x in form.items():
|
|
140
|
+
iguales = [k for k, v in q.items() if x[1] not in (None, "") and str(v) == str(x[1])]
|
|
141
|
+
if len(iguales) == 1:
|
|
142
|
+
clave[etiqueta] = iguales[0]
|
|
143
|
+
en_form = {clave[e] for e, x in form.items() if e in clave and len(x) > 3 and x[3]}
|
|
144
|
+
salida.append(f"## {p[0]} {uri} ({ruta['accion'].split(chr(92))[-1]})")
|
|
145
|
+
salida.append("backend exige: " + (", ".join(back) or "nada"))
|
|
146
|
+
if any(len(x) < 4 for x in form.values()):
|
|
147
|
+
salida.append("captura sin la marca de obligatorio del formulario (hecha con un __f anterior): vuelve a capturar")
|
|
148
|
+
continue
|
|
149
|
+
salida += [f"❌ {k}: el backend lo exige y el formulario no lo marca" for k in back if k in clave.values() and k not in en_form]
|
|
150
|
+
salida += [f"❌ «{e}» ({clave[e]}): el formulario lo marca y el backend no lo exige" for e, x in form.items()
|
|
151
|
+
if e in clave and len(x) > 3 and x[3] and clave[e] not in back]
|
|
152
|
+
salida += [f"sin emparejar: {k} (exigido, no lo vi en el formulario)" for k in back if k not in clave.values()]
|
|
153
|
+
return "\n".join(salida) or "la captura no tiene guardados con sección f (formulario)"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mapa(backend, front, consulta, rutas, origen, raiz):
|
|
157
|
+
"""Texto del mapa para `consulta`: módulo del frontend (cruza sus llamadas con las rutas) o fragmento de dirección de la API."""
|
|
158
|
+
mf = vue_ts.cargado(front)
|
|
159
|
+
modulos = {n: m for n, m in mf.items() if consulta in n}
|
|
160
|
+
llamadas = {}
|
|
161
|
+
for nombre, m in mf.items():
|
|
162
|
+
for c in m["llamadas"]:
|
|
163
|
+
metodo, url, donde = re.match(r"(\w+) (.+?) (\S+:\d+)$", c).groups()
|
|
164
|
+
llamadas.setdefault((metodo, normaliza(url)), []).append(f"{front.relative_to(raiz)}/{donde}")
|
|
165
|
+
salida = [f"Backend de «{consulta}»: de la ruta del front al endpoint (dirección de la API), controlador, servicio y tabla. Sigue: leer(controlador, método).",
|
|
166
|
+
f"rutas leídas de: {origen}"]
|
|
167
|
+
if modulos:
|
|
168
|
+
for n, m in modulos.items():
|
|
169
|
+
salida.append(f"## front {n}")
|
|
170
|
+
salida += [f"ruta {r['path']} -> {r['vista']} {front.relative_to(raiz)}/{r.get('en', '?')}" for r in m["rutas"]]
|
|
171
|
+
pedidas = {normaliza(c.split(" ", 1)[1].rsplit(" ", 1)[0]) for m in modulos.values() for c in m["llamadas"]}
|
|
172
|
+
elegidas = [r for r in rutas if r["uri"] in pedidas]
|
|
173
|
+
sin_ruta = pedidas - {r["uri"] for r in elegidas}
|
|
174
|
+
else:
|
|
175
|
+
elegidas, sin_ruta = [r for r in rutas if consulta in r["uri"]], set()
|
|
176
|
+
controladores = {}
|
|
177
|
+
salida.append("## backend")
|
|
178
|
+
for r in elegidas:
|
|
179
|
+
clase, _, metodo = r["accion"].partition("@")
|
|
180
|
+
archivo = archivo_de(backend, clase)
|
|
181
|
+
linea = ""
|
|
182
|
+
if archivo:
|
|
183
|
+
controladores[clase] = archivo
|
|
184
|
+
n = next((i for i, l in enumerate(archivo.read_text(errors="ignore").splitlines(), 1) if re.search(rf"function {metodo}\(", l)), 0)
|
|
185
|
+
linea = f" {archivo.relative_to(raiz)}:{n}"
|
|
186
|
+
cruce = ", ".join(llamadas.get((r["metodo"], r["uri"]), [])[:2])
|
|
187
|
+
salida.append(f"api {r['metodo']} {r['uri']} -> {clase.split(chr(92))[-1]}@{metodo}{linea}" + (f" <- {cruce}" if cruce else ""))
|
|
188
|
+
salida += [f"api sin ruta en el backend: {u}" for u in sorted(sin_ruta)]
|
|
189
|
+
servicios, modelos = {}, {}
|
|
190
|
+
for archivo in controladores.values():
|
|
191
|
+
for s in importa(archivo, "Services"):
|
|
192
|
+
if (f := archivo_de(backend, s)):
|
|
193
|
+
servicios[s] = f
|
|
194
|
+
modelos.update({m: archivo_de(backend, m) for m in importa(archivo, "Models")})
|
|
195
|
+
for f in list(servicios.values()):
|
|
196
|
+
modelos.update({m: archivo_de(backend, m) for m in importa(f, "Models")})
|
|
197
|
+
salida += [f"servicio {f.relative_to(raiz)}" for f in servicios.values()]
|
|
198
|
+
salida += [f"modelo {f.relative_to(raiz)} -> tabla {tabla_de(f) or '?'}" for f in modelos.values() if f]
|
|
199
|
+
return "\n".join(salida) if elegidas or modulos else f"sin módulo ni ruta que contenga «{consulta}»"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
if len(sys.argv) < 4:
|
|
204
|
+
sys.exit(__doc__)
|
|
205
|
+
raiz = Path.cwd()
|
|
206
|
+
backend, front = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()
|
|
207
|
+
contenedor = os.environ.get("WEBSCOPE_CONTENEDOR") or proyecto.config()["contenedor"]
|
|
208
|
+
rutas = rutas_artisan(contenedor) if contenedor else None
|
|
209
|
+
origen = "artisan route:list" if rutas else "archivos Routes/*.php (sin contenedor o Docker apagado)"
|
|
210
|
+
if "--obligatorios" in sys.argv:
|
|
211
|
+
print(cruce_obligatorios(backend, rutas or rutas_archivos(backend), sys.argv[sys.argv.index("--obligatorios") + 1]))
|
|
212
|
+
tablas = [l.rsplit(" ", 1)[1] for l in mapa(backend, front, sys.argv[3], rutas or rutas_archivos(backend), origen, raiz).splitlines()
|
|
213
|
+
if l.startswith("modelo ") and not l.endswith(" ?")]
|
|
214
|
+
sys.exit(print("\n".join(f"limpieza: limpiar_zz {t} --prefijo <prefijo del pedido> (halla la columna sola)" for t in tablas)))
|
|
215
|
+
print(mapa(backend, front, sys.argv[3], rutas or rutas_archivos(backend), origen, raiz))
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Borra de la base las filas de prueba cuyo código empieza por `ZZ_`, con escritura controlada (proyectos Laravel).
|
|
3
|
+
|
|
4
|
+
Uso: python3 -I limpiar_zz.py <tabla> [<columna>] [--prefijo ZZ_E2E] [--llave COL --hijas tabla.col,tabla.col] [--borrar]
|
|
5
|
+
Sin columna, usa la única columna de texto que tenga filas con el prefijo (si son varias, pide la columna).
|
|
6
|
+
Sin `--borrar` solo lista las filas (solo lectura). Con `--borrar` guarda las filas en `~/.cache/webscope/zz-respaldo/`,
|
|
7
|
+
borra con el script `tinker` del manifiesto (`<script> <archivo.php> --commit`) únicamente si el número de filas es el mismo
|
|
8
|
+
que mostró la lista, y vuelve a contar.
|
|
9
|
+
Con `--llave` y `--hijas` también borra, en la misma transacción, las filas de cada tabla hija cuya columna tiene la llave
|
|
10
|
+
de una fila padre encontrada (p. ej. lo que siembra crear una cotización).
|
|
11
|
+
El prefijo debe empezar por `ZZ_` y tener al menos 2 caracteres más: filas ajenas como `ZZTAB1` nunca coinciden.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
import proyecto # noqa: E402
|
|
23
|
+
|
|
24
|
+
RESPALDO = Path.home() / ".cache" / "webscope" / "zz-respaldo"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validar(tabla, columna, prefijo):
|
|
28
|
+
"""Lanza ValueError si la tabla, la columna o el prefijo no son seguros para armar la consulta."""
|
|
29
|
+
if not re.fullmatch(r"\w+", tabla) or not re.fullmatch(r"\w+", columna):
|
|
30
|
+
raise ValueError("tabla y columna: solo letras, dígitos y _")
|
|
31
|
+
if not re.fullmatch(r"ZZ_[A-Za-z0-9_]{2,}", prefijo):
|
|
32
|
+
raise ValueError("el prefijo debe ser ZZ_ más al menos 2 caracteres de letras, dígitos o _")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def filas(tabla, columna, prefijo):
|
|
36
|
+
"""Filas de la tabla cuyo `columna` empieza por `prefijo`, como lista de {columna: valor}."""
|
|
37
|
+
cols = [f[0] for f in proyecto.consulta(f"SHOW COLUMNS FROM {tabla}")]
|
|
38
|
+
datos = proyecto.consulta(f"SELECT * FROM {tabla} WHERE LEFT({columna}, {len(prefijo)}) = '{prefijo}'")
|
|
39
|
+
return [dict(zip(cols, (None if v == "\\N" else v for v in f))) for f in datos]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def oculto(fila):
|
|
43
|
+
"""La fila con `***` en las columnas de contraseñas, tokens y secretos (el respaldo las guarda completas)."""
|
|
44
|
+
return {k: "***" if v and re.search(r"pass|token|secret|clave", k, re.I) else v for k, v in fila.items()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def columnas_con_prefijo(tabla, prefijo):
|
|
48
|
+
"""Columnas de texto de la tabla con alguna fila que empieza por `prefijo`."""
|
|
49
|
+
texto = [f[0] for f in proyecto.consulta(f"SHOW COLUMNS FROM {tabla}") if re.search(r"char|text", f[1], re.I)]
|
|
50
|
+
if not texto:
|
|
51
|
+
return []
|
|
52
|
+
sumas = ", ".join(f"SUM(LEFT(`{c}`, {len(prefijo)}) = '{prefijo}')" for c in texto)
|
|
53
|
+
cuentas = proyecto.consulta(f"SELECT {sumas} FROM {tabla}")[0]
|
|
54
|
+
return [c for c, n in zip(texto, cuentas) if n not in ("\\N", "0", "")]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validar_hijas(hijas, llaves):
|
|
58
|
+
"""Lanza ValueError si una tabla.columna hija o un valor de llave no son seguros para armar la consulta."""
|
|
59
|
+
for h in hijas:
|
|
60
|
+
if not re.fullmatch(r"\w+\.\w+", h):
|
|
61
|
+
raise ValueError(f"hija `{h}`: debe ser tabla.columna")
|
|
62
|
+
for v in llaves:
|
|
63
|
+
if not re.fullmatch(r"[\w.+\-]+", v):
|
|
64
|
+
raise ValueError(f"llave `{v}`: solo letras, dígitos, _, ., + y -")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def filas_hijas(hija, llaves):
|
|
68
|
+
"""Filas de `tabla.columna` cuya columna está en `llaves`, como lista de {columna: valor}."""
|
|
69
|
+
tabla, col = hija.split(".")
|
|
70
|
+
cols = [f[0] for f in proyecto.consulta(f"SHOW COLUMNS FROM {tabla}")]
|
|
71
|
+
lista = ",".join(f"'{v}'" for v in llaves)
|
|
72
|
+
datos = proyecto.consulta(f"SELECT * FROM {tabla} WHERE {col} IN ({lista})")
|
|
73
|
+
return [dict(zip(cols, (None if v == "\\N" else v for v in f))) for f in datos]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def script_php(tabla, columna, prefijo, esperadas, hijas=None):
|
|
77
|
+
"""PHP para el script `tinker` del proyecto: borra las hijas y luego las filas con ese prefijo, y falla (rollback) si algún número no coincide.
|
|
78
|
+
|
|
79
|
+
`hijas` es {tabla.columna: (llaves, filas esperadas)}.
|
|
80
|
+
"""
|
|
81
|
+
donde = f"->whereRaw('LEFT(`{columna}`, {len(prefijo)}) = ?', ['{prefijo}'])"
|
|
82
|
+
php = f"$db = {proyecto.config()['php_db']};\n$db->transaction(function () use ($db) {{\n"
|
|
83
|
+
db = "$db"
|
|
84
|
+
for hija, (llaves, n) in (hijas or {}).items():
|
|
85
|
+
t, c = hija.split(".")
|
|
86
|
+
lista = ", ".join(f"'{v}'" for v in llaves)
|
|
87
|
+
php += (f"$b = {db}->table('{t}')->whereIn('{c}', [{lista}])->delete();\n"
|
|
88
|
+
f"if ($b !== {int(n)}) throw new RuntimeException(\"{hija}: se esperaban {int(n)} y se borraron $b\");\n"
|
|
89
|
+
f"echo \"{hija}: borradas $b\" . PHP_EOL;\n")
|
|
90
|
+
php += (f"$n = {db}->table('{tabla}'){donde}->count();\n"
|
|
91
|
+
f"if ($n !== {int(esperadas)}) throw new RuntimeException(\"se esperaban {int(esperadas)} filas y hay $n\");\n"
|
|
92
|
+
f"$b = {db}->table('{tabla}'){donde}->delete();\n"
|
|
93
|
+
f"echo \"borradas $b filas\" . PHP_EOL;\n")
|
|
94
|
+
return php + "});\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv):
|
|
98
|
+
"""Lista las filas ZZ_ y, con --borrar, las respalda, las borra y comprueba que no queden."""
|
|
99
|
+
borrar = "--borrar" in argv
|
|
100
|
+
argv = [a for a in argv if a != "--borrar"]
|
|
101
|
+
prefijo = "ZZ_E2E"
|
|
102
|
+
if "--prefijo" in argv:
|
|
103
|
+
i = argv.index("--prefijo")
|
|
104
|
+
prefijo = argv[i + 1]
|
|
105
|
+
argv = argv[:i] + argv[i + 2:]
|
|
106
|
+
opc = {}
|
|
107
|
+
for nombre in ("--llave", "--hijas"):
|
|
108
|
+
if nombre in argv:
|
|
109
|
+
i = argv.index(nombre)
|
|
110
|
+
opc[nombre] = argv[i + 1]
|
|
111
|
+
argv = argv[:i] + argv[i + 2:]
|
|
112
|
+
if len(argv) not in (1, 2) or ("--hijas" in opc) != ("--llave" in opc):
|
|
113
|
+
print(__doc__, file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
tabla, columna = (argv + [None])[:2]
|
|
116
|
+
try:
|
|
117
|
+
validar(tabla, columna or "x", prefijo)
|
|
118
|
+
if "--llave" in opc:
|
|
119
|
+
validar(tabla, opc["--llave"], prefijo)
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
print(f"ERR {e}", file=sys.stderr)
|
|
122
|
+
return 2
|
|
123
|
+
if not proyecto.consulta(f"SHOW TABLES LIKE '{tabla}'"):
|
|
124
|
+
print(f"ERR la tabla {tabla} no existe: el nombre de la API no siempre es la tabla; "
|
|
125
|
+
"`mapa_back <módulo>` da la real en la línea «modelo … -> tabla …»", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
if columna is None:
|
|
128
|
+
cols = columnas_con_prefijo(tabla, prefijo)
|
|
129
|
+
if not cols:
|
|
130
|
+
print(f"{tabla}: ninguna columna de texto empieza por {prefijo}: 0 filas")
|
|
131
|
+
return 0
|
|
132
|
+
if len(cols) > 1:
|
|
133
|
+
print(f"ERR {tabla}: {prefijo} aparece en {', '.join(cols)}; pasa la columna", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
columna = cols[0]
|
|
136
|
+
encontradas = filas(tabla, columna, prefijo)
|
|
137
|
+
print(f"{tabla}.{columna} empieza por {prefijo}: {len(encontradas)} filas")
|
|
138
|
+
for f in encontradas:
|
|
139
|
+
print(" " + json.dumps(oculto(f), ensure_ascii=False))
|
|
140
|
+
hijas, respaldo = {}, {tabla: encontradas}
|
|
141
|
+
if "--hijas" in opc and encontradas:
|
|
142
|
+
llaves = sorted({str(f[opc["--llave"]]) for f in encontradas})
|
|
143
|
+
lista_hijas = opc["--hijas"].split(",")
|
|
144
|
+
try:
|
|
145
|
+
validar_hijas(lista_hijas, llaves)
|
|
146
|
+
except ValueError as e:
|
|
147
|
+
print(f"ERR {e}", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
for h in lista_hijas:
|
|
150
|
+
fh = filas_hijas(h, llaves)
|
|
151
|
+
hijas[h] = (llaves, len(fh))
|
|
152
|
+
respaldo[h] = fh
|
|
153
|
+
print(f" hija {h} con {opc['--llave']} en {llaves}: {len(fh)} filas")
|
|
154
|
+
if not borrar or not encontradas:
|
|
155
|
+
print("Sin cambios." if not borrar else "Nada que borrar.")
|
|
156
|
+
return 0
|
|
157
|
+
if not proyecto.config()["tinker"]:
|
|
158
|
+
print("ERR el proyecto no define `tinker` en la sección `proyecto` del manifiesto")
|
|
159
|
+
return 1
|
|
160
|
+
RESPALDO.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
archivo = RESPALDO / f"{tabla}-{time.strftime('%Y%m%d-%H%M%S')}.json"
|
|
162
|
+
archivo.write_text(json.dumps(respaldo if hijas else encontradas, ensure_ascii=False, indent=1))
|
|
163
|
+
with tempfile.NamedTemporaryFile("w", suffix=".php") as php:
|
|
164
|
+
php.write(script_php(tabla, columna, prefijo, len(encontradas), hijas))
|
|
165
|
+
php.flush()
|
|
166
|
+
p = subprocess.run([str(proyecto.RAIZ / proyecto.config()["tinker"]), php.name, "--commit"], capture_output=True, text=True)
|
|
167
|
+
print((p.stdout + p.stderr).strip()[-300:])
|
|
168
|
+
if p.returncode:
|
|
169
|
+
return 1
|
|
170
|
+
quedan = len(filas(tabla, columna, prefijo))
|
|
171
|
+
print(f"Respaldo: {archivo}. Quedan {quedan} filas.")
|
|
172
|
+
return 0 if quedan == 0 else 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cruza lo que la pantalla envía y recibe (captura de `e2e_chrome.py --detalle`) con el FormRequest, el Resource y la tabla del backend.
|
|
3
|
+
|
|
4
|
+
Uso: python3 -I payload_check.py <captura.json> <Area/Modulo> <tabla> [--llave col1,col2]
|
|
5
|
+
python3 -I payload_check.py --foto <tabla> <valor1,valor2> --llave col1,col2 (foto de la fila antes de editar)
|
|
6
|
+
Si la captura trae la sección `f` (campos del formulario, de `e2e_chrome --form`), marca lo que se puso y no viajó y, con
|
|
7
|
+
foto previa, las columnas que cambiaron en la base sin que el usuario las editara.
|
|
8
|
+
Con `--llave` (columnas que identifican la fila, en el orden de la ruta: `tipo,codigo`) lee la fila de la base tras cada
|
|
9
|
+
POST/PUT/DELETE y la compara con lo enviado; corre el chequeo después de cada acción de la pantalla (`--detalle <desde>`)
|
|
10
|
+
para ver el estado tras esa acción: la fila se compara con la última escritura de cada llave.
|
|
11
|
+
La captura es la lista de intercambios {m: método, u: ruta, s: estado, q: cuerpo enviado, r: forma de la respuesta}.
|
|
12
|
+
Imprime una línea por hallazgo (❌ rompe, ⚠️ riesgo de datos, ❓ revisar); lee la base en solo lectura con proyecto.consulta.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
|
+
import proyecto # noqa: E402
|
|
22
|
+
|
|
23
|
+
FOTOS = Path(tempfile.gettempdir()) / "webscope-fotos"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def valor(texto):
|
|
27
|
+
"""Convierte `tipo(valor)` de la captura en el valor de Python; `obj{...}` y `array3{...}` (claves de la primera fila) dan
|
|
28
|
+
un dict de claves; `array3` y `null` quedan como texto o None."""
|
|
29
|
+
m = re.fullmatch(r"(string|number|boolean)\((.*)\)", texto, re.S)
|
|
30
|
+
lista = re.fullmatch(r"array\d+\{(.*)\}", texto)
|
|
31
|
+
if texto.startswith("obj{") or lista:
|
|
32
|
+
return {k: None for k in (lista.group(1) if lista else texto[4:-1]).split(",") if k}
|
|
33
|
+
if not m:
|
|
34
|
+
return None if texto == "null" else texto
|
|
35
|
+
tipo, v = m.groups()
|
|
36
|
+
return v if tipo == "string" else (float(v) if "." in v else int(v)) if tipo == "number" else v == "true"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def campos(texto):
|
|
40
|
+
"""Pares campo -> valor de una sección `campo:tipo(valor) campo:tipo(valor)` de la captura; None si es `-`."""
|
|
41
|
+
if texto.strip() in ("", "-"):
|
|
42
|
+
return None
|
|
43
|
+
return {k: valor(v) for k, v in re.findall(r"(\w+):((?:string|number|boolean)\([^)]*\)|null|array\d+(?:\{[^}]*\})?|obj\{[^}]*\})", texto)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def leer_captura(texto):
|
|
47
|
+
"""Intercambios de una captura: JSON, o una línea por petición `MÉTODO estado ruta q <campos> r <campos>`."""
|
|
48
|
+
if texto.lstrip().startswith("["):
|
|
49
|
+
return json.loads(texto)
|
|
50
|
+
salida = []
|
|
51
|
+
for linea in texto.strip().splitlines():
|
|
52
|
+
m = re.fullmatch(r"(\w+) (\w+) (\S+) q (.*?) r (.*?)(?: f (\{.*\}))?", linea.strip())
|
|
53
|
+
if m:
|
|
54
|
+
metodo, estado, ruta, q, r, f = m.groups()
|
|
55
|
+
x = {"m": metodo, "s": estado if estado == "ERR" else int(estado), "u": ruta, "q": campos(q), "r": campos(r)}
|
|
56
|
+
if f:
|
|
57
|
+
x["f"] = json.loads(f)
|
|
58
|
+
salida.append(x)
|
|
59
|
+
return salida
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def claves_recurso(php):
|
|
63
|
+
"""Claves que devuelve un Resource: los nombres a la izquierda de `'clave' => $this->…`, con o sin cast `(float)`."""
|
|
64
|
+
return re.findall(r"'(\w+)'\s*=>\s*(?:\(\w+\)\s*)?\$this->", php)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def comparar(intercambios, reglas, mapa, columnas, distintos, recurso):
|
|
68
|
+
"""Líneas de hallazgo al cruzar los cuerpos enviados y las respuestas con reglas, columnas, valores en la tabla y Resource.
|
|
69
|
+
|
|
70
|
+
`columnas` es {columna: nullable}; `distintos` es {columna: valores que hoy tiene la tabla}.
|
|
71
|
+
"""
|
|
72
|
+
sal = []
|
|
73
|
+
for x in intercambios:
|
|
74
|
+
nombre = f"{x['m']} {x['u']}"
|
|
75
|
+
if x["s"] == "ERR" or int(x["s"]) >= 400:
|
|
76
|
+
sal.append(f"❌ {nombre}: respondió {x['s']} con {json.dumps(x.get('r'), ensure_ascii=False)[:160]}")
|
|
77
|
+
continue
|
|
78
|
+
q = x.get("q")
|
|
79
|
+
if x["m"] in ("POST", "PUT", "PATCH") and isinstance(q, dict):
|
|
80
|
+
for k in q:
|
|
81
|
+
if k not in reglas:
|
|
82
|
+
sal.append(f"❓ {nombre}: envía `{k}` y el FormRequest no tiene regla para ese campo")
|
|
83
|
+
for k, r in reglas.items():
|
|
84
|
+
if r["required"] and k not in q:
|
|
85
|
+
sal.append(f"❌ {nombre}: falta `{k}`, obligatorio en el FormRequest")
|
|
86
|
+
for k, v in q.items():
|
|
87
|
+
r = reglas.get(k)
|
|
88
|
+
col = mapa.get(k)
|
|
89
|
+
if r and r["in"] and v not in r["in"]:
|
|
90
|
+
sal.append(f"❌ {nombre}: `{k}`={v!r} no está en in:{','.join(r['in'])}")
|
|
91
|
+
if v == "" and col and columnas.get(col):
|
|
92
|
+
sal.append(f"❓ {nombre}: `{k}` viaja como '' y la columna `{col}` admite NULL; queda '' en la base")
|
|
93
|
+
if r and r["max"] and isinstance(v, str) and len(v) > r["max"]:
|
|
94
|
+
sal.append(f"❌ {nombre}: `{k}` mide {len(v)} y la regla permite {r['max']}")
|
|
95
|
+
if isinstance(x.get("r"), dict) and recurso:
|
|
96
|
+
obj = next((v for v in x["r"].values() if isinstance(v, dict)), None)
|
|
97
|
+
if obj is not None and set(obj) != set(recurso):
|
|
98
|
+
sal.append(f"❓ {nombre}: la respuesta trae {sorted(obj)} y el Resource declara {sorted(recurso)}")
|
|
99
|
+
for k, r in reglas.items():
|
|
100
|
+
col = mapa.get(k)
|
|
101
|
+
if r["in"] and col and distintos.get(col):
|
|
102
|
+
fuera = sorted(v for v in distintos[col] if v not in r["in"])
|
|
103
|
+
if fuera:
|
|
104
|
+
sal.append(f"⚠️ `{k}` acepta solo {','.join(r['in'])} y la tabla (`{col}`) tiene también {', '.join(map(repr, fuera))}: esas filas no se pueden guardar desde la pantalla")
|
|
105
|
+
return sal
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def elegir_request(q, candidatos):
|
|
109
|
+
"""Nombre del FormRequest de `candidatos` ({nombre: reglas}) que cubre más claves de `q` y deja menos obligatorias sin enviar; None si `q` no trae claves."""
|
|
110
|
+
# shortcut: elige por claves, no por la ruta; resolver controlador→Request con mapa_back si dos Requests empatan.
|
|
111
|
+
if not isinstance(q, dict) or not q or not candidatos:
|
|
112
|
+
return None
|
|
113
|
+
mejor = max(sorted(candidatos), key=lambda n: (len(set(q) & set(candidatos[n])),
|
|
114
|
+
-sum(r["required"] and k not in q for k, r in candidatos[n].items())))
|
|
115
|
+
return mejor if set(q) & set(candidatos[mejor]) else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def llave_de(x, llaves, inverso):
|
|
119
|
+
"""Valores de la llave de la fila que tocó el intercambio: del cuerpo en POST, de los últimos segmentos de la ruta en PUT/PATCH/DELETE.
|
|
120
|
+
|
|
121
|
+
`llaves` son columnas, `inverso` es {campo: columna}; devuelve None si no se puede deducir.
|
|
122
|
+
"""
|
|
123
|
+
if x["m"] == "POST":
|
|
124
|
+
campo = {c: f for f, c in inverso.items()}
|
|
125
|
+
q = x.get("q") or {}
|
|
126
|
+
valores = [q.get(campo.get(c)) for c in llaves]
|
|
127
|
+
else:
|
|
128
|
+
segmentos = x["u"].strip("/").split("/")
|
|
129
|
+
valores = segmentos[-len(llaves):] if len(segmentos) > len(llaves) else None
|
|
130
|
+
return None if not valores or any(v in (None, "") for v in valores) else [str(v) for v in valores]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def comparar_fila(x, fila, inverso, estados):
|
|
134
|
+
"""Hallazgos al comparar la fila leída de la base (`{columna: valor}`, None si no existe) con el intercambio `x`.
|
|
135
|
+
|
|
136
|
+
`estados` son las columnas de reglas `in:` (p. ej. status), que se muestran tras un DELETE.
|
|
137
|
+
"""
|
|
138
|
+
nombre = f"{x['m']} {x['u']}"
|
|
139
|
+
if x["m"] == "DELETE":
|
|
140
|
+
if fila is None:
|
|
141
|
+
return [f"✅ {nombre}: la fila ya no está en la tabla (borrado duro)"]
|
|
142
|
+
visto = ", ".join(f"{c}={fila[c]!r}" for c in estados if c in fila)
|
|
143
|
+
return [f"❓ {nombre}: la fila sigue en la tabla ({visto or 'sin columna de estado conocida'}): borrado lógico, no queda limpia"]
|
|
144
|
+
if fila is None:
|
|
145
|
+
return [f"❌ {nombre}: respondió {x['s']} y la fila no existe en la tabla"]
|
|
146
|
+
sal = []
|
|
147
|
+
for campo, v in (x.get("q") or {}).items():
|
|
148
|
+
col = inverso.get(campo)
|
|
149
|
+
if col is None or col not in fila or isinstance(v, dict):
|
|
150
|
+
continue
|
|
151
|
+
guardado = fila[col]
|
|
152
|
+
if guardado is None and v == "":
|
|
153
|
+
sal.append(f"⚠️ {nombre}: `{campo}` envió '' y la base tiene NULL")
|
|
154
|
+
elif guardado is None or str(guardado) != str(v):
|
|
155
|
+
sal.append(f"❌ {nombre}: `{campo}` envió {v!r} y la base tiene {guardado!r}")
|
|
156
|
+
return sal or [f"✅ {nombre}: la fila en la base coincide con lo enviado"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def leer_fila(tabla, columnas, llaves, valores):
|
|
160
|
+
"""Fila de `tabla` con esa llave como {columna: valor}; None si no existe. Valores restringidos a letras, dígitos, `_`, `.` y `-` antes de consultar."""
|
|
161
|
+
if not all(re.fullmatch(r"[\w.\-]+", v) for v in valores) or not all(re.fullmatch(r"\w+", c) for c in llaves):
|
|
162
|
+
return None
|
|
163
|
+
donde = " AND ".join(f"{c} = '{v}'" for c, v in zip(llaves, valores))
|
|
164
|
+
filas = proyecto.consulta(f"SELECT * FROM {tabla} WHERE {donde}")
|
|
165
|
+
return dict(zip(columnas, (None if v == "\\N" else v for v in filas[0]))) if filas else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def editados(x):
|
|
169
|
+
"""Campos que el usuario cambió en el formulario: {etiqueta: valor después}, de la sección `f` ({etiqueta: [antes, después, visto]})."""
|
|
170
|
+
return {k: v[1] for k, v in (x.get("f") or {}).items() if v[1] not in (None, "") and v[0] != v[1]}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def comparar_formulario(x):
|
|
174
|
+
"""Hallazgos de lo que el usuario puso en el formulario y no viajó en el cuerpo enviado."""
|
|
175
|
+
q = x.get("q")
|
|
176
|
+
if not isinstance(q, dict):
|
|
177
|
+
return []
|
|
178
|
+
enviados = {str(v) for v in q.values() if v is not None}
|
|
179
|
+
return [f"❌ {x['m']} {x['u']}: en «{k}» pusiste {v!r} y no viajó en el cuerpo"
|
|
180
|
+
for k, v in editados(x).items() if str(v) not in enviados]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ruta_foto(tabla, valores):
|
|
184
|
+
"""Archivo temporal con la foto de la fila de `tabla` y esa llave."""
|
|
185
|
+
return FOTOS / f"{tabla}-{'-'.join(valores)}.json"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cambios_sin_editar(antes, fila, inverso, x):
|
|
189
|
+
"""Columnas del formulario que cambiaron en la base entre la foto `antes` y `fila` sin que el usuario las editara."""
|
|
190
|
+
puestos = {str(v) for v in editados(x).values()}
|
|
191
|
+
columnas = set(inverso.values())
|
|
192
|
+
return [f"⚠️ {x['m']} {x['u']}: `{c}` pasó de {antes[c]!r} a {fila[c]!r} y no lo editaste en el formulario"
|
|
193
|
+
for c in sorted(columnas & set(antes) & set(fila))
|
|
194
|
+
if str(antes[c]) != str(fila[c]) and str(fila[c]) not in puestos]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def filas_finales(intercambios, llaves, inverso, columnas, tabla, estados):
|
|
198
|
+
"""Hallazgos de fila para el último intercambio de escritura de cada llave; con foto previa y formulario, también lo que cambió sin editarse."""
|
|
199
|
+
ultimos = {}
|
|
200
|
+
for x in intercambios:
|
|
201
|
+
if x["m"] in ("POST", "PUT", "PATCH", "DELETE") and x["s"] != "ERR" and int(x["s"]) < 400:
|
|
202
|
+
v = llave_de(x, llaves, inverso)
|
|
203
|
+
if v:
|
|
204
|
+
ultimos[tuple(v)] = x
|
|
205
|
+
sal = []
|
|
206
|
+
for v, x in ultimos.items():
|
|
207
|
+
fila = leer_fila(tabla, list(columnas), llaves, v)
|
|
208
|
+
sal += comparar_fila(x, fila, inverso, estados)
|
|
209
|
+
foto = ruta_foto(tabla, v)
|
|
210
|
+
if fila and x["m"] in ("PUT", "PATCH") and x.get("f") and foto.exists():
|
|
211
|
+
sal += cambios_sin_editar(json.loads(foto.read_text()), fila, inverso, x)
|
|
212
|
+
return sal
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv):
|
|
216
|
+
"""Carga la captura, lee reglas, Resource y tabla, e imprime los hallazgos."""
|
|
217
|
+
llaves = []
|
|
218
|
+
if "--llave" in argv:
|
|
219
|
+
i = argv.index("--llave")
|
|
220
|
+
llaves = argv[i + 1].split(",")
|
|
221
|
+
argv = argv[:i] + argv[i + 2:]
|
|
222
|
+
if argv[:1] == ["--foto"] and len(argv) == 3 and llaves:
|
|
223
|
+
tabla, valores = argv[1], argv[2].split(",")
|
|
224
|
+
columnas = [f[0] for f in proyecto.consulta(f"SHOW COLUMNS FROM {tabla}")] if re.fullmatch(r"\w+", tabla) else []
|
|
225
|
+
fila = leer_fila(tabla, columnas, llaves, valores) if columnas and len(valores) == len(llaves) else None
|
|
226
|
+
if fila is None:
|
|
227
|
+
print(f"❌ no hay fila en {tabla} con {llaves}={valores}")
|
|
228
|
+
return 1
|
|
229
|
+
FOTOS.mkdir(exist_ok=True)
|
|
230
|
+
ruta_foto(tabla, valores).write_text(json.dumps(fila, ensure_ascii=False))
|
|
231
|
+
print(f"Foto de {tabla} {valores} guardada ({len(fila)} columnas). Sigue: edita en la pantalla y corre payload_check con --llave.")
|
|
232
|
+
return 0
|
|
233
|
+
if len(argv) != 3:
|
|
234
|
+
print(__doc__, file=sys.stderr)
|
|
235
|
+
return 2
|
|
236
|
+
captura, modulo, tabla = argv
|
|
237
|
+
area, nombre = modulo.split("/")
|
|
238
|
+
base = proyecto.modulo(f"{area}/{nombre}")
|
|
239
|
+
carp = proyecto.config()["carpetas"]
|
|
240
|
+
candidatos = {p.stem: proyecto.reglas_back(p.read_text())
|
|
241
|
+
for p in (base / carp["requests"]).glob("*Request.php") if "Filter" not in p.name}
|
|
242
|
+
servicios = "".join(p.read_text() for p in (base / carp["servicios"]).glob("*.php"))
|
|
243
|
+
inverso = {campo: col for col, campo in re.findall(r"'(\w+)'\s*=>\s*\$data\['(\w+)'\]", servicios)}
|
|
244
|
+
recurso = claves_recurso(next((base / carp["recursos"]).glob("*Resource.php")).read_text()) if (base / carp["recursos"]).exists() else []
|
|
245
|
+
columnas = {f[0]: f[2] == "YES" for f in proyecto.consulta(f"SHOW COLUMNS FROM {tabla}")}
|
|
246
|
+
intercambios = leer_captura(Path(captura).read_text())
|
|
247
|
+
grupos = {}
|
|
248
|
+
for x in intercambios:
|
|
249
|
+
grupos.setdefault(elegir_request(x.get("q"), candidatos), []).append(x)
|
|
250
|
+
lineas, estados = [], []
|
|
251
|
+
for nombre_req, xs in grupos.items():
|
|
252
|
+
reglas = candidatos.get(nombre_req, {})
|
|
253
|
+
distintos = {}
|
|
254
|
+
for k, r in reglas.items():
|
|
255
|
+
col = inverso.get(k)
|
|
256
|
+
if r["in"] and col:
|
|
257
|
+
distintos[col] = {f[0] for f in proyecto.consulta(f"SELECT DISTINCT {col} FROM {tabla}")}
|
|
258
|
+
lineas += comparar(xs, reglas, inverso, columnas, distintos, recurso)
|
|
259
|
+
estados += [inverso[k] for k, r in reglas.items() if r["in"] and k in inverso and inverso[k] not in estados]
|
|
260
|
+
for x in intercambios:
|
|
261
|
+
lineas += comparar_formulario(x)
|
|
262
|
+
if not inverso:
|
|
263
|
+
lineas.append("❓ no pude mapear campos a columnas (los Services no usan `'columna' => $data['campo']`): solo se revisó el FormRequest")
|
|
264
|
+
if llaves:
|
|
265
|
+
lineas += filas_finales(intercambios, llaves, inverso, columnas, tabla, estados)
|
|
266
|
+
print(f"Verificación de {tabla}: cruza la captura con {', '.join(sorted(n for n in grupos if n)) or 'ningún FormRequest'}, el Resource y las columnas. Sigue: revisar cada línea.")
|
|
267
|
+
print("\n".join(lineas) or "sin hallazgos")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
sys.exit(main(sys.argv[1:]))
|