disensor 0.1.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.
- disensor/__init__.py +2 -0
- disensor/__main__.py +3 -0
- disensor/cli.py +62 -0
- disensor/gate.py +245 -0
- disensor/plantilla.py +86 -0
- disensor/reglas.py +197 -0
- disensor/render.py +123 -0
- disensor/residuo.schema.json +354 -0
- disensor/vectores.py +189 -0
- disensor-0.1.0.dist-info/METADATA +95 -0
- disensor-0.1.0.dist-info/RECORD +15 -0
- disensor-0.1.0.dist-info/WHEEL +5 -0
- disensor-0.1.0.dist-info/entry_points.txt +2 -0
- disensor-0.1.0.dist-info/licenses/LICENSE +21 -0
- disensor-0.1.0.dist-info/top_level.txt +1 -0
disensor/__init__.py
ADDED
disensor/__main__.py
ADDED
disensor/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Interfaz de linea de comandos: disensor {nuevo, validar, gate}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .gate import main_gate
|
|
10
|
+
from .plantilla import main_nuevo
|
|
11
|
+
from .reglas import cargar_schema, validar_artefacto
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main_validar(args) -> int:
|
|
15
|
+
schema = cargar_schema()
|
|
16
|
+
fallo = False
|
|
17
|
+
for ruta in args.archivos:
|
|
18
|
+
with open(ruta, encoding="utf-8") as f:
|
|
19
|
+
artefacto = json.load(f)
|
|
20
|
+
errores = validar_artefacto(artefacto, schema)
|
|
21
|
+
print(f"{ruta}: {'VALIDO' if not errores else 'INVALIDO'}")
|
|
22
|
+
for msg in errores:
|
|
23
|
+
print(f" {msg}")
|
|
24
|
+
fallo = fallo or bool(errores)
|
|
25
|
+
return 1 if fallo else 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def construir_parser() -> argparse.ArgumentParser:
|
|
29
|
+
p = argparse.ArgumentParser(
|
|
30
|
+
prog="disensor",
|
|
31
|
+
description="Declaracion de residuo de revision adversarial (desacuerdo controlado).",
|
|
32
|
+
)
|
|
33
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
34
|
+
|
|
35
|
+
nuevo = sub.add_parser("nuevo", help="Crea una plantilla de artefacto para el evento en curso.")
|
|
36
|
+
nuevo.add_argument("--directorio", default=".residuo")
|
|
37
|
+
nuevo.add_argument("--compuerta", choices=["plan", "diff", "arquitectura"], default="diff")
|
|
38
|
+
nuevo.add_argument("--nivel", choices=["A", "B", "C"], default="B")
|
|
39
|
+
nuevo.add_argument("--perfil", choices=["completo", "minimizado"], default="completo")
|
|
40
|
+
nuevo.set_defaults(func=main_nuevo)
|
|
41
|
+
|
|
42
|
+
validar = sub.add_parser("validar", help="Valida artefactos contra el schema y las reglas R0 a R10.")
|
|
43
|
+
validar.add_argument("archivos", nargs="+")
|
|
44
|
+
validar.set_defaults(func=main_validar)
|
|
45
|
+
|
|
46
|
+
gate = sub.add_parser("gate", help="Gate de CI: valida el PR completo y aplica politica G1 a G5.")
|
|
47
|
+
gate.add_argument("--directorio", default=".residuo")
|
|
48
|
+
gate.add_argument("--config", default="disensor.config.json")
|
|
49
|
+
gate.add_argument("--base", default=None, help="SHA base del PR (por defecto, el evento de GitHub).")
|
|
50
|
+
gate.add_argument("--cabeza", default=None, help="SHA cabeza del PR (por defecto, el evento de GitHub).")
|
|
51
|
+
gate.add_argument("--sin-comentario", action="store_true", help="No publicar comentario en el PR.")
|
|
52
|
+
gate.set_defaults(func=main_gate)
|
|
53
|
+
return p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
args = construir_parser().parse_args()
|
|
58
|
+
sys.exit(args.func(args))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
disensor/gate.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Gate de CI: valida las declaraciones de residuo de un PR y aplica politica.
|
|
2
|
+
|
|
3
|
+
Chequeos del gate (sobre los R0 a R10 por artefacto de reglas.py):
|
|
4
|
+
G1: al menos un artefacto valido en el rango del PR, salvo que la
|
|
5
|
+
configuracion declare el gate como no requerido (tipico en Nivel C).
|
|
6
|
+
G2: el nivel del artefacto coincide con el nivel declarado del repositorio
|
|
7
|
+
(seccion 12.2: nivel de criticidad declarado en un archivo del repo).
|
|
8
|
+
G3: Nivel A bloqueado mientras la gobernanza no este validada
|
|
9
|
+
(seccion 3.1: el Nivel A no arranca hasta que la seccion 10 este validada).
|
|
10
|
+
G4: politica de confinamiento por nivel (seccion 10: el revisor solo lee,
|
|
11
|
+
y eso se garantiza con permisos y no con la consigna).
|
|
12
|
+
G5: el commit revisado pertenece al rango del PR.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.request
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .reglas import cargar_schema, validar_artefacto
|
|
24
|
+
from .render import MARCADOR, render_comentario
|
|
25
|
+
|
|
26
|
+
CONFIG_DEFECTO = {
|
|
27
|
+
"nivel_criticidad": "B",
|
|
28
|
+
"nivel_A_habilitado": False,
|
|
29
|
+
"gate": {
|
|
30
|
+
"requerido": True,
|
|
31
|
+
"confinamiento_aceptado_nivel_A": ["permisos", "sandbox"],
|
|
32
|
+
"advertir_confinamiento_no_verificado": True,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cargar_config(ruta: Path) -> dict:
|
|
38
|
+
if not ruta.exists():
|
|
39
|
+
return dict(CONFIG_DEFECTO)
|
|
40
|
+
with ruta.open(encoding="utf-8") as f:
|
|
41
|
+
config = json.load(f)
|
|
42
|
+
combinada = dict(CONFIG_DEFECTO)
|
|
43
|
+
combinada.update({k: v for k, v in config.items() if k != "gate"})
|
|
44
|
+
gate = dict(CONFIG_DEFECTO["gate"])
|
|
45
|
+
gate.update(config.get("gate", {}))
|
|
46
|
+
combinada["gate"] = gate
|
|
47
|
+
return combinada
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rango_del_pr(base: str | None, cabeza: str | None) -> tuple[str | None, str | None]:
|
|
51
|
+
"""Base y cabeza del PR: flags primero, despues el evento de GitHub."""
|
|
52
|
+
if base and cabeza:
|
|
53
|
+
return base, cabeza
|
|
54
|
+
ruta_evento = os.environ.get("GITHUB_EVENT_PATH")
|
|
55
|
+
if ruta_evento and Path(ruta_evento).exists():
|
|
56
|
+
with open(ruta_evento, encoding="utf-8") as f:
|
|
57
|
+
evento = json.load(f)
|
|
58
|
+
pr = evento.get("pull_request") or {}
|
|
59
|
+
return (
|
|
60
|
+
base or (pr.get("base") or {}).get("sha"),
|
|
61
|
+
cabeza or (pr.get("head") or {}).get("sha"),
|
|
62
|
+
)
|
|
63
|
+
return base, cabeza
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def commits_del_rango(base: str, cabeza: str, cwd: Path) -> list[str] | None:
|
|
67
|
+
"""Commits del rango base..cabeza, incluida la cabeza.
|
|
68
|
+
|
|
69
|
+
Devuelve None si git no puede resolver el rango (clon superficial, shas
|
|
70
|
+
desconocidos): eso es una advertencia. Una lista vacia es un resultado
|
|
71
|
+
valido y significa que el rango no contiene commits: G5 debe evaluarse
|
|
72
|
+
igual, porque un rango vacio no puede contener el commit revisado.
|
|
73
|
+
"""
|
|
74
|
+
r = subprocess.run(
|
|
75
|
+
["git", "rev-list", f"{base}..{cabeza}"],
|
|
76
|
+
capture_output=True, text=True, cwd=cwd, check=False,
|
|
77
|
+
)
|
|
78
|
+
if r.returncode != 0:
|
|
79
|
+
return None
|
|
80
|
+
return [linea.strip() for linea in r.stdout.splitlines() if linea.strip()]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def numero_de_pr() -> int | None:
|
|
84
|
+
ruta_evento = os.environ.get("GITHUB_EVENT_PATH")
|
|
85
|
+
if ruta_evento and Path(ruta_evento).exists():
|
|
86
|
+
with open(ruta_evento, encoding="utf-8") as f:
|
|
87
|
+
evento = json.load(f)
|
|
88
|
+
pr = evento.get("pull_request") or {}
|
|
89
|
+
return pr.get("number")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def publicar_comentario(cuerpo: str) -> str:
|
|
94
|
+
"""Crea o actualiza el comentario del gate en el PR. Devuelve un estado legible."""
|
|
95
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
96
|
+
repo = os.environ.get("GITHUB_REPOSITORY")
|
|
97
|
+
pr = numero_de_pr()
|
|
98
|
+
if not (token and repo and pr):
|
|
99
|
+
return "sin token, repositorio o numero de PR: comentario no publicado"
|
|
100
|
+
api = os.environ.get("GITHUB_API_URL", "https://api.github.com")
|
|
101
|
+
cabeceras = {
|
|
102
|
+
"Authorization": f"Bearer {token}",
|
|
103
|
+
"Accept": "application/vnd.github+json",
|
|
104
|
+
"User-Agent": "disensor-gate",
|
|
105
|
+
}
|
|
106
|
+
try:
|
|
107
|
+
req = urllib.request.Request(
|
|
108
|
+
f"{api}/repos/{repo}/issues/{pr}/comments?per_page=100", headers=cabeceras
|
|
109
|
+
)
|
|
110
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
111
|
+
comentarios = json.load(resp)
|
|
112
|
+
existente = next((c for c in comentarios if MARCADOR in (c.get("body") or "")), None)
|
|
113
|
+
datos = json.dumps({"body": cuerpo}).encode("utf-8")
|
|
114
|
+
if existente:
|
|
115
|
+
req = urllib.request.Request(
|
|
116
|
+
f"{api}/repos/{repo}/issues/comments/{existente['id']}",
|
|
117
|
+
data=datos, headers=cabeceras, method="PATCH",
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
f"{api}/repos/{repo}/issues/{pr}/comments",
|
|
122
|
+
data=datos, headers=cabeceras, method="POST",
|
|
123
|
+
)
|
|
124
|
+
with urllib.request.urlopen(req, timeout=30):
|
|
125
|
+
pass
|
|
126
|
+
return "comentario actualizado" if existente else "comentario publicado"
|
|
127
|
+
except Exception as exc: # el gate no debe caerse por la red
|
|
128
|
+
return f"no se pudo publicar el comentario: {exc}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def escribir_resumen(cuerpo: str) -> None:
|
|
132
|
+
ruta = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
133
|
+
if ruta:
|
|
134
|
+
with open(ruta, "a", encoding="utf-8") as f:
|
|
135
|
+
f.write(cuerpo + "\n")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def correr_gate(directorio: Path, config_ruta: Path, base: str | None,
|
|
139
|
+
cabeza: str | None, repo_dir: Path, publicar: bool = True) -> int:
|
|
140
|
+
config = cargar_config(config_ruta)
|
|
141
|
+
schema = cargar_schema()
|
|
142
|
+
gate_cfg = config["gate"]
|
|
143
|
+
|
|
144
|
+
errores_gate: list[str] = []
|
|
145
|
+
advertencias: list[str] = []
|
|
146
|
+
errores_por_archivo: dict[str, list[str]] = {}
|
|
147
|
+
validos: list[dict] = []
|
|
148
|
+
|
|
149
|
+
base, cabeza = rango_del_pr(base, cabeza)
|
|
150
|
+
commits = commits_del_rango(base, cabeza, repo_dir) if (base and cabeza) else None
|
|
151
|
+
if commits is None and base and cabeza:
|
|
152
|
+
advertencias.append(
|
|
153
|
+
"no se pudo resolver el rango de commits (checkout con fetch-depth 0): G5 no se evalua"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
archivos = sorted(p for p in directorio.glob("*.json")) if directorio.exists() else []
|
|
157
|
+
for ruta in archivos:
|
|
158
|
+
with ruta.open(encoding="utf-8") as f:
|
|
159
|
+
try:
|
|
160
|
+
artefacto = json.load(f)
|
|
161
|
+
except json.JSONDecodeError as exc:
|
|
162
|
+
errores_por_archivo[ruta.name] = [f"JSON invalido: {exc}"]
|
|
163
|
+
continue
|
|
164
|
+
errores = validar_artefacto(artefacto, schema)
|
|
165
|
+
if errores:
|
|
166
|
+
errores_por_archivo[ruta.name] = errores
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
ev = artefacto["evento"]
|
|
170
|
+
propios: list[str] = []
|
|
171
|
+
|
|
172
|
+
# G2: nivel del artefacto contra nivel declarado del repositorio.
|
|
173
|
+
if ev["nivel_criticidad"] != config["nivel_criticidad"]:
|
|
174
|
+
propios.append(
|
|
175
|
+
f"[G2] nivel del artefacto ({ev['nivel_criticidad']}) distinto del declarado "
|
|
176
|
+
f"en el repositorio ({config['nivel_criticidad']})"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# G3: Nivel A bloqueado hasta validar la gobernanza (seccion 3.1).
|
|
180
|
+
if ev["nivel_criticidad"] == "A" and not config.get("nivel_A_habilitado", False):
|
|
181
|
+
propios.append(
|
|
182
|
+
"[G3] Nivel A bloqueado: la gobernanza de datos (seccion 10) no esta validada "
|
|
183
|
+
"en este repositorio (nivel_A_habilitado=false). Aplicar el protocolo en Nivel A "
|
|
184
|
+
"en esta situacion no es cumplirlo a medias: es violarlo."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# G4: politica de confinamiento por nivel.
|
|
188
|
+
for r in artefacto["actores"]["revisores"]:
|
|
189
|
+
conf = r["confinamiento"]
|
|
190
|
+
if ev["nivel_criticidad"] == "A" and conf["modo"] not in gate_cfg["confinamiento_aceptado_nivel_A"]:
|
|
191
|
+
propios.append(
|
|
192
|
+
f"[G4] revisor {r['id_revisor']}: confinamiento '{conf['modo']}' no admitido en Nivel A "
|
|
193
|
+
f"(el revisor solo lee, y eso se garantiza con permisos y no con la consigna)"
|
|
194
|
+
)
|
|
195
|
+
if not conf["verificado"] and gate_cfg.get("advertir_confinamiento_no_verificado", True):
|
|
196
|
+
advertencias.append(
|
|
197
|
+
f"{ruta.name}: confinamiento del revisor {r['id_revisor']} sin verificacion posterior"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# G5: el commit revisado pertenece al rango del PR.
|
|
201
|
+
if commits is not None:
|
|
202
|
+
cc = ev["commit_cabeza"]
|
|
203
|
+
if not any(c.startswith(cc) or cc.startswith(c) for c in commits):
|
|
204
|
+
propios.append(
|
|
205
|
+
f"[G5] commit revisado {cc} fuera del rango del PR ({base[:7]}..{cabeza[:7]})"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if propios:
|
|
209
|
+
errores_por_archivo[ruta.name] = propios
|
|
210
|
+
else:
|
|
211
|
+
validos.append(artefacto)
|
|
212
|
+
|
|
213
|
+
# G1: al menos un artefacto valido, salvo gate no requerido.
|
|
214
|
+
if gate_cfg.get("requerido", True) and not validos:
|
|
215
|
+
errores_gate.append(
|
|
216
|
+
"[G1] ningun artefacto de residuo valido para este PR "
|
|
217
|
+
f"(directorio '{directorio}'). La declaracion lista residuo concreto o dice "
|
|
218
|
+
"explicitamente que no hubo ninguno; un campo vacio no es una declaracion."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
cuerpo = render_comentario(validos, errores_por_archivo, errores_gate)
|
|
222
|
+
if advertencias:
|
|
223
|
+
cuerpo += "\n\nAdvertencias:\n" + "\n".join(f"- {a}" for a in advertencias)
|
|
224
|
+
|
|
225
|
+
escribir_resumen(cuerpo)
|
|
226
|
+
if publicar:
|
|
227
|
+
estado = publicar_comentario(cuerpo)
|
|
228
|
+
print(f"[gate] {estado}")
|
|
229
|
+
|
|
230
|
+
fallo = bool(errores_gate or errores_por_archivo)
|
|
231
|
+
print(cuerpo)
|
|
232
|
+
print(f"\n[gate] {'FALLO' if fallo else 'OK'}: {len(validos)} artefacto(s) valido(s), "
|
|
233
|
+
f"{len(errores_por_archivo)} con errores, {len(errores_gate)} error(es) globales")
|
|
234
|
+
return 1 if fallo else 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main_gate(args) -> int:
|
|
238
|
+
return correr_gate(
|
|
239
|
+
directorio=Path(args.directorio),
|
|
240
|
+
config_ruta=Path(args.config),
|
|
241
|
+
base=args.base,
|
|
242
|
+
cabeza=args.cabeza,
|
|
243
|
+
repo_dir=Path.cwd(),
|
|
244
|
+
publicar=not args.sin_comentario,
|
|
245
|
+
)
|
disensor/plantilla.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Scaffolding de un artefacto nuevo: `disensor nuevo`.
|
|
2
|
+
|
|
3
|
+
Genera una plantilla prellenada con lo que git ya sabe (repositorio, commits,
|
|
4
|
+
timestamp) y marcadores que no pasan la validacion hasta completarse. Una
|
|
5
|
+
plantilla que valida vacia seria cumplimiento cosmetico de fabrica.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _git(args: list[str], cwd: Path) -> str:
|
|
17
|
+
r = subprocess.run(["git", *args], capture_output=True, text=True, cwd=cwd, check=False)
|
|
18
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plantilla(compuerta: str, nivel: str, perfil: str, cwd: Path) -> dict:
|
|
22
|
+
ahora = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
23
|
+
remoto = _git(["config", "--get", "remote.origin.url"], cwd) or "COMPLETAR_repositorio"
|
|
24
|
+
cabeza = _git(["rev-parse", "HEAD"], cwd) or "COMPLETAR"
|
|
25
|
+
base = _git(["merge-base", "HEAD", "origin/main"], cwd) or _git(
|
|
26
|
+
["merge-base", "HEAD", "origin/master"], cwd
|
|
27
|
+
)
|
|
28
|
+
a: dict = {
|
|
29
|
+
"esquema": "residuo/v0.1",
|
|
30
|
+
"perfil": perfil,
|
|
31
|
+
"evento": {
|
|
32
|
+
"id_evento": str(uuid.uuid4()),
|
|
33
|
+
"creado_en": ahora,
|
|
34
|
+
"repositorio": remoto,
|
|
35
|
+
"commit_cabeza": cabeza,
|
|
36
|
+
"compuerta": compuerta,
|
|
37
|
+
"nivel_criticidad": nivel,
|
|
38
|
+
"ruta_abreviada": {"usada": False},
|
|
39
|
+
},
|
|
40
|
+
"actores": {
|
|
41
|
+
"generador": {"familia": "anthropic", "modelo": "claude-code"},
|
|
42
|
+
"revisores": [
|
|
43
|
+
{
|
|
44
|
+
"id_revisor": "r1",
|
|
45
|
+
"familia": "openai",
|
|
46
|
+
"modelo": "COMPLETAR_modelo_del_revisor",
|
|
47
|
+
"confinamiento": {
|
|
48
|
+
"modo": "solo_lectura_por_instruccion",
|
|
49
|
+
"verificado": False,
|
|
50
|
+
"metodo_verificacion": "git_status_limpio",
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"arbitro_humano": {"presente": True},
|
|
55
|
+
},
|
|
56
|
+
"hallazgos": [],
|
|
57
|
+
"residuo": {
|
|
58
|
+
"ausencia_declarada": True,
|
|
59
|
+
"declaracion": "COMPLETAR: declaracion expresa de ausencia, o reemplazar este objeto por items",
|
|
60
|
+
},
|
|
61
|
+
"metricas": {
|
|
62
|
+
"conteos": {
|
|
63
|
+
"total_hallazgos": 0,
|
|
64
|
+
"validos": {"incorporado": 0, "deuda_registrada": 0, "decision_del_dueno": 0},
|
|
65
|
+
"falsos_positivos": {"refutado_verificable": 0, "refutado_interpretativo": 0},
|
|
66
|
+
"escalados_abiertos": 0,
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
if base:
|
|
71
|
+
a["evento"]["commit_base"] = base
|
|
72
|
+
return a
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main_nuevo(args) -> int:
|
|
76
|
+
directorio = Path(args.directorio)
|
|
77
|
+
directorio.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
a = plantilla(args.compuerta, args.nivel, args.perfil, Path.cwd())
|
|
79
|
+
ruta = directorio / f"{a['evento']['id_evento']}.json"
|
|
80
|
+
with ruta.open("w", encoding="utf-8") as f:
|
|
81
|
+
json.dump(a, f, ensure_ascii=False, indent=2)
|
|
82
|
+
f.write("\n")
|
|
83
|
+
print(f"Plantilla creada: {ruta}")
|
|
84
|
+
print("Completar los campos COMPLETAR_ y los hallazgos del evento; despues: disensor validar", ruta)
|
|
85
|
+
print("Nota: la plantilla no valida hasta completarse. Es intencional.")
|
|
86
|
+
return 0
|
disensor/reglas.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Reglas de validacion del artefacto de residuo (esquema v0.1).
|
|
2
|
+
|
|
3
|
+
Dos capas, en el espiritu de la seccion 12.2 del protocolo:
|
|
4
|
+
1. JSON Schema: forma y campos condicionales.
|
|
5
|
+
2. Reglas estructurales R0 a R10: coherencia que un schema no expresa.
|
|
6
|
+
|
|
7
|
+
Limite honesto, declarado en el protocolo: la maquina detecta el campo vacio
|
|
8
|
+
y el marcador generico, no la declaracion falsa. El muestreo humano sigue
|
|
9
|
+
siendo la unica defensa real contra el cumplimiento cosmetico.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from importlib import resources
|
|
15
|
+
|
|
16
|
+
from jsonschema import Draft202012Validator
|
|
17
|
+
|
|
18
|
+
MARCADORES_GENERICOS = {
|
|
19
|
+
"n/a", "na", "ninguno", "ninguna", "todo resuelto", "aplicado",
|
|
20
|
+
"ok", "sin residuo", "-", "no aplica",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
ESTADOS_A_RESIDUO = {"escalado_abierto", "refutado_verificable", "refutado_interpretativo"}
|
|
24
|
+
|
|
25
|
+
CLASE_ESPERADA = {
|
|
26
|
+
"escalado_abierto": "escalado_sin_decision",
|
|
27
|
+
"refutado_verificable": "refutacion_del_principal",
|
|
28
|
+
"refutado_interpretativo": "refutacion_del_principal",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
CAMPOS_TEXTO_PROHIBIDOS_MINIMIZADO = {"titulo", "descripcion", "ubicacion"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cargar_schema() -> dict:
|
|
35
|
+
"""Carga el schema empaquetado con la distribucion."""
|
|
36
|
+
with resources.files("disensor").joinpath("residuo.schema.json").open(encoding="utf-8") as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def errores_schema(artefacto: dict, schema: dict) -> list[str]:
|
|
41
|
+
validador = Draft202012Validator(schema)
|
|
42
|
+
return [
|
|
43
|
+
f"[schema] {'/'.join(str(p) for p in err.path) or '(raiz)'}: {err.message}"
|
|
44
|
+
for err in sorted(validador.iter_errors(artefacto), key=lambda x: list(x.path))
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def errores_reglas(a: dict) -> list[str]:
|
|
49
|
+
"""Reglas estructurales sobre un artefacto ya valido contra el schema."""
|
|
50
|
+
e: list[str] = []
|
|
51
|
+
|
|
52
|
+
def error(regla: str, msg: str) -> None:
|
|
53
|
+
e.append(f"[{regla}] {msg}")
|
|
54
|
+
|
|
55
|
+
perfil = a["perfil"]
|
|
56
|
+
hallazgos = a.get("hallazgos", [])
|
|
57
|
+
residuo = a["residuo"]
|
|
58
|
+
items = residuo.get("items", [])
|
|
59
|
+
por_id = {h["id"]: h for h in hallazgos}
|
|
60
|
+
compuerta = a["evento"]["compuerta"]
|
|
61
|
+
nivel = a["evento"]["nivel_criticidad"]
|
|
62
|
+
|
|
63
|
+
# R0: sin arbitro humano el evento no cumple el protocolo (seccion 6).
|
|
64
|
+
if not a["actores"]["arbitro_humano"]["presente"]:
|
|
65
|
+
error("R0", "evento sin arbitro humano presente")
|
|
66
|
+
|
|
67
|
+
# R10: en perfil completo, la lista de hallazgos es obligatoria.
|
|
68
|
+
if perfil == "completo" and not hallazgos:
|
|
69
|
+
error("R10", "perfil completo sin lista de hallazgos")
|
|
70
|
+
|
|
71
|
+
# R1: todo hallazgo cuyo estado integra el residuo tiene su item, y todo
|
|
72
|
+
# item con referencia apunta a un hallazgo existente del estado correcto.
|
|
73
|
+
if hallazgos:
|
|
74
|
+
refs = {i.get("ref_hallazgo") for i in items if i.get("ref_hallazgo")}
|
|
75
|
+
for h in hallazgos:
|
|
76
|
+
if h["estado_final"] in ESTADOS_A_RESIDUO and h["id"] not in refs:
|
|
77
|
+
error("R1", f"hallazgo {h['id']} ({h['estado_final']}) sin item de residuo")
|
|
78
|
+
for i in items:
|
|
79
|
+
ref = i.get("ref_hallazgo")
|
|
80
|
+
if ref:
|
|
81
|
+
if ref not in por_id:
|
|
82
|
+
error("R1", f"item {i['id']} referencia hallazgo inexistente {ref}")
|
|
83
|
+
else:
|
|
84
|
+
est = por_id[ref]["estado_final"]
|
|
85
|
+
if est in CLASE_ESPERADA and i["clase"] != CLASE_ESPERADA[est]:
|
|
86
|
+
error("R1", f"item {i['id']} clase {i['clase']} no corresponde al estado {est} de {ref}")
|
|
87
|
+
|
|
88
|
+
# R2: sin marcadores genericos en las declaraciones de texto.
|
|
89
|
+
textos = []
|
|
90
|
+
if residuo.get("ausencia_declarada"):
|
|
91
|
+
textos.append(("residuo.declaracion", residuo.get("declaracion", "")))
|
|
92
|
+
for i in items:
|
|
93
|
+
if "descripcion" in i:
|
|
94
|
+
textos.append((f"residuo.items[{i['id']}].descripcion", i["descripcion"]))
|
|
95
|
+
for donde, t in textos:
|
|
96
|
+
if t.strip().lower() in MARCADORES_GENERICOS:
|
|
97
|
+
error("R2", f"marcador generico en {donde}: '{t}'")
|
|
98
|
+
|
|
99
|
+
# R2 bis: marcadores de plantilla sin completar, en cualquier campo.
|
|
100
|
+
# Sin esto, una plantilla de perfil minimizado validaria de fabrica.
|
|
101
|
+
def buscar_plantilla(valor, ruta: str) -> None:
|
|
102
|
+
if isinstance(valor, str) and valor.startswith("COMPLETAR"):
|
|
103
|
+
error("R2", f"marcador de plantilla sin completar en {ruta}")
|
|
104
|
+
elif isinstance(valor, dict):
|
|
105
|
+
for k, v in valor.items():
|
|
106
|
+
buscar_plantilla(v, f"{ruta}.{k}")
|
|
107
|
+
elif isinstance(valor, list):
|
|
108
|
+
for n, v in enumerate(valor):
|
|
109
|
+
buscar_plantilla(v, f"{ruta}[{n}]")
|
|
110
|
+
|
|
111
|
+
buscar_plantilla(a, "artefacto")
|
|
112
|
+
|
|
113
|
+
# R3: ruta abreviada incompatible con los cinco casos protegidos (3.2).
|
|
114
|
+
ra = a["evento"]["ruta_abreviada"]
|
|
115
|
+
if ra.get("usada") and ra.get("casos_protegidos_tocados"):
|
|
116
|
+
error("R3", "ruta abreviada usada sobre un cambio que toca casos protegidos de 3.2")
|
|
117
|
+
|
|
118
|
+
# R4: decorrelacion: ningun revisor modelo comparte familia con el generador.
|
|
119
|
+
fam_gen = a["actores"]["generador"]["familia"]
|
|
120
|
+
for r in a["actores"]["revisores"]:
|
|
121
|
+
if r["familia"] == fam_gen:
|
|
122
|
+
error("R4", f"revisor {r['id_revisor']} comparte familia ({fam_gen}) con el generador: sin decorrelacion")
|
|
123
|
+
|
|
124
|
+
# R5: Nivel A + gap de ejecucion exige aceptacion escrita de un referente (seccion 7).
|
|
125
|
+
if nivel == "A":
|
|
126
|
+
for i in items:
|
|
127
|
+
if i["clase"] == "gap_de_ejecucion" and "aceptacion_referente" not in i:
|
|
128
|
+
error("R5", f"item {i['id']}: gap de ejecucion en Nivel A sin aceptacion de referente (bloquea el merge)")
|
|
129
|
+
|
|
130
|
+
# R6: conteos coherentes con la lista de hallazgos (si esta presente).
|
|
131
|
+
if hallazgos:
|
|
132
|
+
c = a["metricas"]["conteos"]
|
|
133
|
+
|
|
134
|
+
def conteo(est: str) -> int:
|
|
135
|
+
return sum(1 for h in hallazgos if h["estado_final"] == est)
|
|
136
|
+
|
|
137
|
+
esperado = {
|
|
138
|
+
("validos", "incorporado"): conteo("incorporado"),
|
|
139
|
+
("validos", "deuda_registrada"): conteo("deuda_registrada"),
|
|
140
|
+
("validos", "decision_del_dueno"): conteo("decision_del_dueno"),
|
|
141
|
+
("falsos_positivos", "refutado_verificable"): conteo("refutado_verificable"),
|
|
142
|
+
("falsos_positivos", "refutado_interpretativo"): conteo("refutado_interpretativo"),
|
|
143
|
+
}
|
|
144
|
+
for (grupo, campo), v in esperado.items():
|
|
145
|
+
if c[grupo][campo] != v:
|
|
146
|
+
error("R6", f"conteo {grupo}.{campo}={c[grupo][campo]} pero la lista tiene {v}")
|
|
147
|
+
if c["escalados_abiertos"] != conteo("escalado_abierto"):
|
|
148
|
+
error("R6", f"escalados_abiertos={c['escalados_abiertos']} pero la lista tiene {conteo('escalado_abierto')}")
|
|
149
|
+
if c["total_hallazgos"] != len(hallazgos):
|
|
150
|
+
error("R6", f"total_hallazgos={c['total_hallazgos']} pero la lista tiene {len(hallazgos)}")
|
|
151
|
+
|
|
152
|
+
# R7: en compuerta diff, todo incorporado cierra con la correccion verificada.
|
|
153
|
+
if compuerta == "diff":
|
|
154
|
+
for h in hallazgos:
|
|
155
|
+
if h["estado_final"] == "incorporado":
|
|
156
|
+
v = h.get("verificacion_de_la_correccion")
|
|
157
|
+
if not v:
|
|
158
|
+
error("R7", f"hallazgo {h['id']} incorporado en compuerta diff sin verificacion de la correccion")
|
|
159
|
+
elif v["tipo"] == "pendiente_en_compuerta_diff":
|
|
160
|
+
error("R7", f"hallazgo {h['id']}: la verificacion de la correccion no puede quedar pendiente en la propia compuerta diff")
|
|
161
|
+
|
|
162
|
+
# R8: refutacion interpretativa siempre pide atencion humana (seccion 6).
|
|
163
|
+
for i in items:
|
|
164
|
+
if i.get("tipo_refutacion") == "interpretativa" and not i.get("requiere_atencion_humana"):
|
|
165
|
+
error("R8", f"item {i['id']}: refutacion interpretativa con requiere_atencion_humana=false")
|
|
166
|
+
|
|
167
|
+
# R9: perfil minimizado no admite texto libre.
|
|
168
|
+
if perfil == "minimizado":
|
|
169
|
+
for h in hallazgos:
|
|
170
|
+
for campo in CAMPOS_TEXTO_PROHIBIDOS_MINIMIZADO & h.keys():
|
|
171
|
+
error("R9", f"hallazgo {h['id']}: campo '{campo}' prohibido en perfil minimizado")
|
|
172
|
+
ev = h.get("evidencia", {})
|
|
173
|
+
if "texto" in ev or "enlace" in ev:
|
|
174
|
+
error("R9", f"hallazgo {h['id']}: evidencia con texto o enlace en perfil minimizado (solo hash)")
|
|
175
|
+
for i in items:
|
|
176
|
+
if "descripcion" in i:
|
|
177
|
+
error("R9", f"item {i['id']}: descripcion prohibida en perfil minimizado")
|
|
178
|
+
ev = i.get("evidencia", {})
|
|
179
|
+
if "texto" in ev or "enlace" in ev:
|
|
180
|
+
error("R9", f"item {i['id']}: evidencia con texto o enlace en perfil minimizado (solo hash)")
|
|
181
|
+
if a["evento"]["repositorio"].startswith("http"):
|
|
182
|
+
error("R9", "perfil minimizado con URL de repositorio en claro (usar hash o identificador opaco)")
|
|
183
|
+
|
|
184
|
+
return e
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validar_artefacto(artefacto: dict, schema: dict | None = None) -> list[str]:
|
|
188
|
+
"""Valida un artefacto completo. Devuelve la lista de errores (vacia si es valido).
|
|
189
|
+
|
|
190
|
+
Si el schema falla, las reglas estructurales no se evaluan: operarian
|
|
191
|
+
sobre una forma que no esta garantizada.
|
|
192
|
+
"""
|
|
193
|
+
schema = schema or cargar_schema()
|
|
194
|
+
errores = errores_schema(artefacto, schema)
|
|
195
|
+
if errores:
|
|
196
|
+
return errores
|
|
197
|
+
return errores_reglas(artefacto)
|